From 99428823879d6bb044dd36840933c9907aaebfe8 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/49] 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 aef4730e6058975f66830c2c452bd7ac3faab366 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/49] 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 8626f78cb7..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.3-SNAPSHOT + 5.3.0-SNAPSHOT bootstrapper diff --git a/caffeine-bounded-cache-support/pom.xml b/caffeine-bounded-cache-support/pom.xml index 151083a082..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.3-SNAPSHOT + 5.3.0-SNAPSHOT caffeine-bounded-cache-support diff --git a/micrometer-support/pom.xml b/micrometer-support/pom.xml index 56b78197a5..93765e4a8a 100644 --- a/micrometer-support/pom.xml +++ b/micrometer-support/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.3-SNAPSHOT + 5.3.0-SNAPSHOT micrometer-support diff --git a/operator-framework-bom/pom.xml b/operator-framework-bom/pom.xml index 8793a087fa..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.3-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 3b9bcbc471..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.3-SNAPSHOT + 5.3.0-SNAPSHOT ../pom.xml diff --git a/operator-framework-junit5/pom.xml b/operator-framework-junit5/pom.xml index 62e351241a..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.3-SNAPSHOT + 5.3.0-SNAPSHOT operator-framework-junit-5 diff --git a/operator-framework/pom.xml b/operator-framework/pom.xml index 549d6b9e2e..253907eb1e 100644 --- a/operator-framework/pom.xml +++ b/operator-framework/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.3-SNAPSHOT + 5.3.0-SNAPSHOT operator-framework diff --git a/pom.xml b/pom.xml index ffb51cb7cf..4725d8b2b6 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.3-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 67b04eb31a..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.3-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 1feb5ffb2e..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.3-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 77b105e0c6..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.3-SNAPSHOT + 5.3.0-SNAPSHOT sample-mysql-schema-operator diff --git a/sample-operators/pom.xml b/sample-operators/pom.xml index 9387ffaa9a..6079d3bb71 100644 --- a/sample-operators/pom.xml +++ b/sample-operators/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.2.3-SNAPSHOT + 5.3.0-SNAPSHOT sample-operators diff --git a/sample-operators/tomcat-operator/pom.xml b/sample-operators/tomcat-operator/pom.xml index 1d58e67575..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.3-SNAPSHOT + 5.3.0-SNAPSHOT sample-tomcat-operator diff --git a/sample-operators/webpage/pom.xml b/sample-operators/webpage/pom.xml index f6fa568dd1..e25920b7da 100644 --- a/sample-operators/webpage/pom.xml +++ b/sample-operators/webpage/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.2.3-SNAPSHOT + 5.3.0-SNAPSHOT sample-webpage-operator From 3aa3cbb4146a60931235bc0afa704957bb38ac08 Mon Sep 17 00:00:00 2001 From: Steven Hawkins Date: Thu, 30 Oct 2025 08:22:09 -0400 Subject: [PATCH 03/49] 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 a4949d061dcced4a7b4c4089aa693de3c59f17bd 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/49] 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 22442ab98df7c9992749070b139e055c13f25114 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/49] 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 a637c72927..172f28e3f9 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 658423b900641650041117b0be4ae403dc878b01 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/49] 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 e9ac365ad9..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.3-SNAPSHOT + 5.3.0-SNAPSHOT test-index-processor From d09a5753d5886b954b1886df3641d4a83e1424a9 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/49] 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 5eb8d10f6b04b39d716b9689555901baac5de7b8 Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Wed, 3 Dec 2025 09:47:12 +0100 Subject: [PATCH 08/49] 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 379e727d85176f2f20a098404d4129f796410289 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/49] 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 bba9aa9c70..7378d32a1a 100644 --- a/bootstrapper-maven-plugin/src/main/resources/templates/pom.xml +++ b/bootstrapper-maven-plugin/src/main/resources/templates/pom.xml @@ -58,7 +58,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 4725d8b2b6..1504496a50 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 efc2e8f28db62329877b9382d9295571018251a5 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/49] 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 700d642b794c397945b98fb54cc7ae3c72461c98 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/49] 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 0a33293b53..1faa545f54 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; @@ -140,7 +140,7 @@ public static Builder builder() { } public static void applyCrd(Class resourceClass, KubernetesClient client) { - applyCrd(ReconcilerUtils.getResourceTypeName(resourceClass), client); + applyCrd(ReconcilerUtilsInternal.getResourceTypeName(resourceClass), client); } /** @@ -192,7 +192,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)); } /** @@ -203,7 +203,7 @@ public void applyCrd(Class crClass) { * * @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 @@ -296,7 +296,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 fdc210d6ddd97147a7b3441005a4c6d23e0a8bb0 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/49] 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 03073abce60eec5aed7623f937b3023b7e377a62 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/49] 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 e17a7395a04a372ca86a906cdf3dc296a83fe29a 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/49] 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 f3caf00b7db64455cde3200187beea27dca1118f 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/49] 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 258d97573b2b17c22f1ac621d542483e3a5182db 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/49] 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 4f1d0115fd2aba42f69ed2d9b331c5fece38ca81 Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Tue, 3 Feb 2026 12:09:24 +0100 Subject: [PATCH 17/49] 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 8fd7dedfd4616d9a48d747cf18a0f97feb3aec85 Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Thu, 5 Feb 2026 16:20:48 +0100 Subject: [PATCH 18/49] 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 a1827910691e97f7c17bb1436677b1acc26745bf 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/49] 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 280b162797c5e7d291ca72490dca4aa454f903d2 Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Fri, 6 Feb 2026 16:22:40 +0100 Subject: [PATCH 20/49] 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 586728d365c6688241e03bffde95b5fb77c347d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 4 Feb 2026 17:07:35 +0100 Subject: [PATCH 21/49] feat: observability with otel and default grafana dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- observability/install-observability.sh | 240 +++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100755 observability/install-observability.sh diff --git a/observability/install-observability.sh b/observability/install-observability.sh new file mode 100755 index 0000000000..2db117e0cd --- /dev/null +++ b/observability/install-observability.sh @@ -0,0 +1,240 @@ +#!/bin/bash + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}Installing Observability Stack${NC}" +echo -e "${GREEN}OpenTelemetry + Prometheus + Grafana${NC}" +echo -e "${GREEN}========================================${NC}" + +# Check if helm is installed +echo -e "\n${YELLOW}Checking helm installation...${NC}" +if ! command -v helm &> /dev/null; then + echo -e "${RED}Error: helm is not installed${NC}" + echo "Please install helm: https://helm.sh/docs/intro/install/" + exit 1 +fi +echo -e "${GREEN}✓ helm is installed${NC}" + +# Add Helm repositories +echo -e "\n${YELLOW}Adding Helm repositories...${NC}" +helm repo add jetstack https://charts.jetstack.io +helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts +helm repo add prometheus-community https://prometheus-community.github.io/helm-charts +helm repo update +echo -e "${GREEN}✓ Helm repositories added${NC}" + +# Install cert-manager (required for OpenTelemetry Operator) +echo -e "\n${YELLOW}Installing cert-manager...${NC}" +if kubectl get namespace cert-manager > /dev/null 2>&1; then + echo -e "${YELLOW}cert-manager namespace already exists, skipping...${NC}" +else + kubectl create namespace cert-manager + helm install cert-manager jetstack/cert-manager \ + --namespace cert-manager \ + --set crds.enabled=true \ + --wait + echo -e "${GREEN}✓ cert-manager installed${NC}" +fi + +# Create observability namespace +echo -e "\n${YELLOW}Creating observability namespace...${NC}" +kubectl create namespace observability --dry-run=client -o yaml | kubectl apply -f - +echo -e "${GREEN}✓ observability namespace ready${NC}" + +# Install OpenTelemetry Operator +echo -e "\n${YELLOW}Installing OpenTelemetry Operator...${NC}" +if helm list -n observability | grep -q opentelemetry-operator; then + echo -e "${YELLOW}OpenTelemetry Operator already installed, upgrading...${NC}" + helm upgrade opentelemetry-operator open-telemetry/opentelemetry-operator \ + --namespace observability \ + --set "manager.collectorImage.repository=otel/opentelemetry-collector-contrib" \ + --wait +else + helm install opentelemetry-operator open-telemetry/opentelemetry-operator \ + --namespace observability \ + --set "manager.collectorImage.repository=otel/opentelemetry-collector-contrib" \ + --wait +fi +echo -e "${GREEN}✓ OpenTelemetry Operator installed${NC}" + +# Install kube-prometheus-stack (includes Prometheus + Grafana) +echo -e "\n${YELLOW}Installing Prometheus and Grafana stack...${NC}" +if helm list -n observability | grep -q kube-prometheus-stack; then + echo -e "${YELLOW}kube-prometheus-stack already installed, upgrading...${NC}" + helm upgrade kube-prometheus-stack prometheus-community/kube-prometheus-stack \ + --namespace observability \ + --set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false \ + --set prometheus.prometheusSpec.podMonitorSelectorNilUsesHelmValues=false \ + --set grafana.adminPassword=admin \ + --wait +else + helm install kube-prometheus-stack prometheus-community/kube-prometheus-stack \ + --namespace observability \ + --set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false \ + --set prometheus.prometheusSpec.podMonitorSelectorNilUsesHelmValues=false \ + --set grafana.adminPassword=admin \ + --wait +fi +echo -e "${GREEN}✓ Prometheus and Grafana installed${NC}" + +# Create OpenTelemetry Collector instance +echo -e "\n${YELLOW}Creating OpenTelemetry Collector...${NC}" +cat < Date: Wed, 4 Feb 2026 17:28:34 +0100 Subject: [PATCH 22/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- observability/install-observability.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/observability/install-observability.sh b/observability/install-observability.sh index 2db117e0cd..e9c42f5968 100755 --- a/observability/install-observability.sh +++ b/observability/install-observability.sh @@ -50,6 +50,7 @@ echo -e "${GREEN}✓ observability namespace ready${NC}" # Install OpenTelemetry Operator echo -e "\n${YELLOW}Installing OpenTelemetry Operator...${NC}" + if helm list -n observability | grep -q opentelemetry-operator; then echo -e "${YELLOW}OpenTelemetry Operator already installed, upgrading...${NC}" helm upgrade opentelemetry-operator open-telemetry/opentelemetry-operator \ From afab176eb9cc79bb6b84faa116316f2207782ef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 4 Feb 2026 17:54:09 +0100 Subject: [PATCH 23/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- observability/install-observability.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/observability/install-observability.sh b/observability/install-observability.sh index e9c42f5968..314ee1e4aa 100755 --- a/observability/install-observability.sh +++ b/observability/install-observability.sh @@ -1,4 +1,19 @@ #!/bin/bash +# +# 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. +# set -e From b0af1e214c010956d9a185ce354cd8649986b5c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 4 Feb 2026 18:05:06 +0100 Subject: [PATCH 24/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .github/workflows/pr.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7a5964ba35..34bc6c7d0f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -11,6 +11,7 @@ on: paths-ignore: - 'docs/**' - 'adr/**' + - 'observability/**' workflow_dispatch: jobs: check_format_and_unit_tests: From edd24fe38f1fdc9bd5789530db4813c820e41863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sun, 8 Feb 2026 16:17:46 +0100 Subject: [PATCH 25/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- grafana/README.md | 225 +++++++++++++++ grafana/install-observability.sh | 264 ++++++++++++++++++ sample-operators/webpage/pom.xml | 14 + .../operator/sample/WebPageOperator.java | 49 +++- .../src/main/resources/otlp-config.yaml | 6 + 5 files changed, 557 insertions(+), 1 deletion(-) create mode 100644 grafana/README.md create mode 100755 grafana/install-observability.sh create mode 100644 sample-operators/webpage/src/main/resources/otlp-config.yaml diff --git a/grafana/README.md b/grafana/README.md new file mode 100644 index 0000000000..35e1167190 --- /dev/null +++ b/grafana/README.md @@ -0,0 +1,225 @@ +# Observability Stack for Java Operator SDK + +This directory contains scripts and configuration for setting up a complete observability stack on minikube. + +## Quick Start + +```bash +./install-observability.sh +``` + +This script installs: +- **OpenTelemetry Operator** - For collecting metrics and traces +- **Prometheus** - For metrics storage and querying +- **Grafana** - For visualization and dashboards +- **cert-manager** - Required for OpenTelemetry Operator webhooks + +## Prerequisites + +- kubectl configured +- Helm 3.x installed + +## Components Installed + +### OpenTelemetry Collector +- Receives metrics and traces via OTLP (gRPC and HTTP) +- Exports metrics to Prometheus format +- Configured with memory limiter and batch processing + +**Endpoints:** +- OTLP gRPC: `otel-collector-collector.observability.svc.cluster.local:4317` +- OTLP HTTP: `otel-collector-collector.observability.svc.cluster.local:4318` +- Prometheus metrics: `http://otel-collector-prometheus.observability.svc.cluster.local:8889/metrics` + +### Prometheus +- Scrapes metrics from OpenTelemetry Collector +- Supports ServiceMonitor and PodMonitor CRDs +- Configured to discover all metrics automatically + +**Access:** +```bash +kubectl port-forward -n observability svc/kube-prometheus-stack-prometheus 9090:9090 +``` +Open http://localhost:9090 + +### Grafana +- Pre-configured with Prometheus as data source +- Includes Kubernetes monitoring dashboards + +**Access:** +```bash +kubectl port-forward -n observability svc/kube-prometheus-stack-grafana 3000:80 +``` +Open http://localhost:3000 +- **Username:** admin +- **Password:** admin + +## Integrating with Your Operator + +### 1. Add OpenTelemetry Dependency + +Add to your `pom.xml`: + +```xml + + io.javaoperatorsdk + operator-framework-opentelemetry-support + ${josdk.version} + +``` + +### 2. Configure OpenTelemetry in Your Operator + +In your operator code: + +```java +import io.javaoperatorsdk.operator.monitoring.opentelemetry.OpenTelemetryMetrics; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk; + +// Initialize OpenTelemetry +OpenTelemetry openTelemetry = AutoConfiguredOpenTelemetrySdk.initialize() + .getOpenTelemetrySdk(); + +// Create JOSDK metrics instance +Metrics metrics = OpenTelemetryMetrics.builder(openTelemetry) + .build(); + +// Configure operator with metrics +Operator operator = new Operator(client, o -> o.withMetrics(metrics)); +``` + +### 3. Set Environment Variables + +In your operator deployment YAML: + +```yaml +env: + - name: OTEL_SERVICE_NAME + value: "your-operator-name" + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: "http://otel-collector-collector.observability.svc.cluster.local:4318" + - name: OTEL_METRICS_EXPORTER + value: "otlp" + - name: OTEL_TRACES_EXPORTER + value: "otlp" + - name: OTEL_EXPORTER_OTLP_PROTOCOL + value: "http/protobuf" +``` + +## Available JOSDK Metrics + +The following metrics are exported by JOSDK: + +| Metric | Type | Description | +|--------|------|-------------| +| `operator_sdk_reconciliations_started_total` | Counter | Total number of reconciliations started | +| `operator_sdk_reconciliations_success_total` | Counter | Total number of successful reconciliations | +| `operator_sdk_reconciliations_failed_total` | Counter | Total number of failed reconciliations | +| `operator_sdk_reconciliations_queue_size` | Gauge | Current reconciliation queue size | +| `operator_sdk_events_received_total` | Counter | Total number of Kubernetes events received | +| `operator_sdk_controllers_execution_reconcile_seconds` | Timer | Time taken for reconciliations | +| `operator_sdk_controllers_execution_cleanup_seconds` | Timer | Time taken for cleanup operations | + +## Creating Grafana Dashboards + +### Example PromQL Queries + +**Reconciliation Rate:** +```promql +sum(rate(operator_sdk_reconciliations_started_total[5m])) by (controller) +``` + +**Success Rate:** +```promql +sum(rate(operator_sdk_reconciliations_success_total[5m])) / +sum(rate(operator_sdk_reconciliations_started_total[5m])) +``` + +**Error Rate:** +```promql +sum(rate(operator_sdk_reconciliations_failed_total[5m])) by (controller, exception) +``` + +**Queue Size:** +```promql +operator_sdk_reconciliations_queue_size +``` + +**Average Reconciliation Duration:** +```promql +rate(operator_sdk_controllers_execution_reconcile_seconds_sum[5m]) / +rate(operator_sdk_controllers_execution_reconcile_seconds_count[5m]) +``` + +### Sample Dashboard Configuration + +1. Open Grafana (http://localhost:3000) +2. Go to "Dashboards" → "New Dashboard" +3. Add panels with the PromQL queries above +4. Configure visualization types: + - Time series for rates and durations + - Gauge for queue size + - Stat for current values + +## Troubleshooting + +### Check Pod Status +```bash +kubectl get pods -n observability +``` + +### Check OpenTelemetry Collector Logs +```bash +kubectl logs -n observability -l app.kubernetes.io/name=otel-collector -f +``` + +### Check Prometheus Targets +```bash +kubectl port-forward -n observability svc/kube-prometheus-stack-prometheus 9090:9090 +``` +Then open http://localhost:9090/targets + +### Verify Metrics are Being Collected +```bash +# Check if OpenTelemetry is receiving metrics +kubectl port-forward -n observability svc/otel-collector-prometheus 8889:8889 +curl http://localhost:8889/metrics | grep operator_sdk +``` + +### Test OTLP Endpoint +```bash +# Port forward the OTLP HTTP endpoint +kubectl port-forward -n observability svc/otel-collector-collector 4318:4318 + +# Send a test metric (requires curl and valid OTLP JSON) +# This is just for testing connectivity +curl -X POST http://localhost:4318/v1/metrics \ + -H "Content-Type: application/json" \ + -d '{"resourceMetrics":[]}' +``` + +## Uninstalling + +To remove all components: + +```bash +# Delete OpenTelemetry resources +kubectl delete -n observability OpenTelemetryCollector otel-collector + +# Uninstall Helm releases +helm uninstall -n observability kube-prometheus-stack +helm uninstall -n observability opentelemetry-operator +helm uninstall -n cert-manager cert-manager + +# Delete namespaces +kubectl delete namespace observability cert-manager +``` + +## References + +- [JOSDK Observability Documentation](https://javaoperatorsdk.io/docs/documentation/observability/) +- [OpenTelemetry Java Documentation](https://opentelemetry.io/docs/instrumentation/java/) +- [Prometheus Operator](https://github.com/prometheus-operator/prometheus-operator) +- [Grafana Documentation](https://grafana.com/docs/) +- [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) diff --git a/grafana/install-observability.sh b/grafana/install-observability.sh new file mode 100755 index 0000000000..63bdcb706f --- /dev/null +++ b/grafana/install-observability.sh @@ -0,0 +1,264 @@ +#!/bin/bash +# +# 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. +# + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN}Installing Observability Stack${NC}" +echo -e "${GREEN}OpenTelemetry + Prometheus + Grafana${NC}" +echo -e "${GREEN}========================================${NC}" + +# Check if minikube is running +echo -e "\n${YELLOW}Checking minikube status...${NC}" +if ! minikube status > /dev/null 2>&1; then + echo -e "${RED}Error: minikube is not running${NC}" + echo "Please start minikube with: minikube start" + exit 1 +fi +echo -e "${GREEN}✓ minikube is running${NC}" + +# Check if helm is installed +echo -e "\n${YELLOW}Checking helm installation...${NC}" +if ! command -v helm &> /dev/null; then + echo -e "${RED}Error: helm is not installed${NC}" + echo "Please install helm: https://helm.sh/docs/intro/install/" + exit 1 +fi +echo -e "${GREEN}✓ helm is installed${NC}" + +# Add Helm repositories +echo -e "\n${YELLOW}Adding Helm repositories...${NC}" +helm repo add jetstack https://charts.jetstack.io +helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts +helm repo add prometheus-community https://prometheus-community.github.io/helm-charts +helm repo update +echo -e "${GREEN}✓ Helm repositories added${NC}" + +# Install cert-manager (required for OpenTelemetry Operator) +echo -e "\n${YELLOW}Installing cert-manager...${NC}" +if kubectl get namespace cert-manager > /dev/null 2>&1; then + echo -e "${YELLOW}cert-manager namespace already exists, skipping...${NC}" +else + kubectl create namespace cert-manager + helm install cert-manager jetstack/cert-manager \ + --namespace cert-manager \ + --set crds.enabled=true \ + --wait + echo -e "${GREEN}✓ cert-manager installed${NC}" +fi + +# Create observability namespace +echo -e "\n${YELLOW}Creating observability namespace...${NC}" +kubectl create namespace observability --dry-run=client -o yaml | kubectl apply -f - +echo -e "${GREEN}✓ observability namespace ready${NC}" + +# Install OpenTelemetry Operator +echo -e "\n${YELLOW}Installing OpenTelemetry Operator...${NC}" +if helm list -n observability | grep -q opentelemetry-operator; then + echo -e "${YELLOW}OpenTelemetry Operator already installed, upgrading...${NC}" + helm upgrade opentelemetry-operator open-telemetry/opentelemetry-operator \ + --namespace observability \ + --set "manager.collectorImage.repository=otel/opentelemetry-collector-contrib" \ + --wait +else + helm install opentelemetry-operator open-telemetry/opentelemetry-operator \ + --namespace observability \ + --set "manager.collectorImage.repository=otel/opentelemetry-collector-contrib" \ + --wait +fi +echo -e "${GREEN}✓ OpenTelemetry Operator installed${NC}" + +# Install kube-prometheus-stack (includes Prometheus + Grafana) +echo -e "\n${YELLOW}Installing Prometheus and Grafana stack...${NC}" +if helm list -n observability | grep -q kube-prometheus-stack; then + echo -e "${YELLOW}kube-prometheus-stack already installed, upgrading...${NC}" + helm upgrade kube-prometheus-stack prometheus-community/kube-prometheus-stack \ + --namespace observability \ + --set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false \ + --set prometheus.prometheusSpec.podMonitorSelectorNilUsesHelmValues=false \ + --set grafana.adminPassword=admin \ + --wait +else + helm install kube-prometheus-stack prometheus-community/kube-prometheus-stack \ + --namespace observability \ + --set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false \ + --set prometheus.prometheusSpec.podMonitorSelectorNilUsesHelmValues=false \ + --set grafana.adminPassword=admin \ + --wait +fi +echo -e "${GREEN}✓ Prometheus and Grafana installed${NC}" + +# Create OpenTelemetry Collector instance +echo -e "\n${YELLOW}Creating OpenTelemetry Collector...${NC}" +kubectl apply -f - <io.javaoperatorsdk operator-framework + + io.javaoperatorsdk + micrometer-support + + + io.micrometer + micrometer-registry-otlp + ${micrometer-core.version} + + + org.yaml + snakeyaml + 2.3 + org.apache.logging.log4j log4j-slf4j2-impl diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java index 5366dc2e9a..78c05f8df7 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java @@ -16,14 +16,25 @@ package io.javaoperatorsdk.operator.sample; import java.io.IOException; +import java.io.InputStream; import java.net.InetSocketAddress; +import java.util.HashMap; +import java.util.Map; +import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.yaml.snakeyaml.Yaml; import io.javaoperatorsdk.operator.Operator; +import io.javaoperatorsdk.operator.api.monitoring.Metrics; +import io.javaoperatorsdk.operator.monitoring.micrometer.MicrometerMetrics; import io.javaoperatorsdk.operator.sample.probes.LivenessHandler; import io.javaoperatorsdk.operator.sample.probes.StartupHandler; +import io.micrometer.core.instrument.Clock; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.registry.otlp.OtlpConfig; +import io.micrometer.registry.otlp.OtlpMeterRegistry; import com.sun.net.httpserver.HttpServer; @@ -40,7 +51,10 @@ public class WebPageOperator { public static void main(String[] args) throws IOException { log.info("WebServer Operator starting!"); - Operator operator = new Operator(o -> o.withStopOnInformerErrorDuringStartup(false)); + // Load configuration from config.yaml + Metrics metrics = initOTLPMetrics(); + Operator operator = + new Operator(o -> o.withStopOnInformerErrorDuringStartup(false).withMetrics(metrics)); String reconcilerEnvVar = System.getenv(WEBPAGE_RECONCILER_ENV); if (WEBPAGE_CLASSIC_RECONCILER_ENV_VALUE.equals(reconcilerEnvVar)) { operator.register(new WebPageReconciler()); @@ -58,4 +72,37 @@ public static void main(String[] args) throws IOException { server.setExecutor(null); server.start(); } + + private static @NonNull Metrics initOTLPMetrics() { + Map configProperties = loadConfigFromYaml(); + OtlpConfig otlpConfig = configProperties::get; + + MeterRegistry registry = new OtlpMeterRegistry(otlpConfig, Clock.SYSTEM); + return MicrometerMetrics.withoutPerResourceMetrics(registry); + } + + @SuppressWarnings("unchecked") + private static Map loadConfigFromYaml() { + Map configMap = new HashMap<>(); + try (InputStream inputStream = WebPageOperator.class.getResourceAsStream("/otlp-config.yaml")) { + if (inputStream == null) { + log.warn("otlp-config.yaml not found in resources, using default OTLP configuration"); + return configMap; + } + + Yaml yaml = new Yaml(); + Map yamlData = yaml.load(inputStream); + + // Navigate to otlp section and map properties directly + Map otlp = (Map) yamlData.get("otlp"); + if (otlp != null) { + otlp.forEach((key, value) -> configMap.put("otlp." + key, value.toString())); + } + + log.info("Loaded OTLP configuration from otlp-config.yaml: {}", configMap); + } catch (IOException e) { + log.error("Error loading otlp-config.yaml", e); + } + return configMap; + } } diff --git a/sample-operators/webpage/src/main/resources/otlp-config.yaml b/sample-operators/webpage/src/main/resources/otlp-config.yaml new file mode 100644 index 0000000000..30d6f283da --- /dev/null +++ b/sample-operators/webpage/src/main/resources/otlp-config.yaml @@ -0,0 +1,6 @@ +otlp: + # OTLP Collector endpoint - see observability/install-observability.sh for setup + url: "http://otel-collector-collector.observability.svc.cluster.local:4318/v1/metrics" + step: 15s + batchSize: 15000 + aggregationTemporality: "cumulative" From ece63e8f6aaca8c4f1ccf27f7bd95e33f39a27ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sun, 8 Feb 2026 16:24:42 +0100 Subject: [PATCH 26/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../webpage/src/main/resources/otlp-config.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/sample-operators/webpage/src/main/resources/otlp-config.yaml b/sample-operators/webpage/src/main/resources/otlp-config.yaml index 30d6f283da..ca93bfc965 100644 --- a/sample-operators/webpage/src/main/resources/otlp-config.yaml +++ b/sample-operators/webpage/src/main/resources/otlp-config.yaml @@ -1,3 +1,19 @@ +# +# 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. +# + otlp: # OTLP Collector endpoint - see observability/install-observability.sh for setup url: "http://otel-collector-collector.observability.svc.cluster.local:4318/v1/metrics" From 72ca6e8ead29f00d590043b4472b0ed60837fd35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sun, 8 Feb 2026 16:26:41 +0100 Subject: [PATCH 27/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- grafana/README.md | 225 -------------------------- grafana/install-observability.sh | 264 ------------------------------- 2 files changed, 489 deletions(-) delete mode 100644 grafana/README.md delete mode 100755 grafana/install-observability.sh diff --git a/grafana/README.md b/grafana/README.md deleted file mode 100644 index 35e1167190..0000000000 --- a/grafana/README.md +++ /dev/null @@ -1,225 +0,0 @@ -# Observability Stack for Java Operator SDK - -This directory contains scripts and configuration for setting up a complete observability stack on minikube. - -## Quick Start - -```bash -./install-observability.sh -``` - -This script installs: -- **OpenTelemetry Operator** - For collecting metrics and traces -- **Prometheus** - For metrics storage and querying -- **Grafana** - For visualization and dashboards -- **cert-manager** - Required for OpenTelemetry Operator webhooks - -## Prerequisites - -- kubectl configured -- Helm 3.x installed - -## Components Installed - -### OpenTelemetry Collector -- Receives metrics and traces via OTLP (gRPC and HTTP) -- Exports metrics to Prometheus format -- Configured with memory limiter and batch processing - -**Endpoints:** -- OTLP gRPC: `otel-collector-collector.observability.svc.cluster.local:4317` -- OTLP HTTP: `otel-collector-collector.observability.svc.cluster.local:4318` -- Prometheus metrics: `http://otel-collector-prometheus.observability.svc.cluster.local:8889/metrics` - -### Prometheus -- Scrapes metrics from OpenTelemetry Collector -- Supports ServiceMonitor and PodMonitor CRDs -- Configured to discover all metrics automatically - -**Access:** -```bash -kubectl port-forward -n observability svc/kube-prometheus-stack-prometheus 9090:9090 -``` -Open http://localhost:9090 - -### Grafana -- Pre-configured with Prometheus as data source -- Includes Kubernetes monitoring dashboards - -**Access:** -```bash -kubectl port-forward -n observability svc/kube-prometheus-stack-grafana 3000:80 -``` -Open http://localhost:3000 -- **Username:** admin -- **Password:** admin - -## Integrating with Your Operator - -### 1. Add OpenTelemetry Dependency - -Add to your `pom.xml`: - -```xml - - io.javaoperatorsdk - operator-framework-opentelemetry-support - ${josdk.version} - -``` - -### 2. Configure OpenTelemetry in Your Operator - -In your operator code: - -```java -import io.javaoperatorsdk.operator.monitoring.opentelemetry.OpenTelemetryMetrics; -import io.opentelemetry.api.OpenTelemetry; -import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk; - -// Initialize OpenTelemetry -OpenTelemetry openTelemetry = AutoConfiguredOpenTelemetrySdk.initialize() - .getOpenTelemetrySdk(); - -// Create JOSDK metrics instance -Metrics metrics = OpenTelemetryMetrics.builder(openTelemetry) - .build(); - -// Configure operator with metrics -Operator operator = new Operator(client, o -> o.withMetrics(metrics)); -``` - -### 3. Set Environment Variables - -In your operator deployment YAML: - -```yaml -env: - - name: OTEL_SERVICE_NAME - value: "your-operator-name" - - name: OTEL_EXPORTER_OTLP_ENDPOINT - value: "http://otel-collector-collector.observability.svc.cluster.local:4318" - - name: OTEL_METRICS_EXPORTER - value: "otlp" - - name: OTEL_TRACES_EXPORTER - value: "otlp" - - name: OTEL_EXPORTER_OTLP_PROTOCOL - value: "http/protobuf" -``` - -## Available JOSDK Metrics - -The following metrics are exported by JOSDK: - -| Metric | Type | Description | -|--------|------|-------------| -| `operator_sdk_reconciliations_started_total` | Counter | Total number of reconciliations started | -| `operator_sdk_reconciliations_success_total` | Counter | Total number of successful reconciliations | -| `operator_sdk_reconciliations_failed_total` | Counter | Total number of failed reconciliations | -| `operator_sdk_reconciliations_queue_size` | Gauge | Current reconciliation queue size | -| `operator_sdk_events_received_total` | Counter | Total number of Kubernetes events received | -| `operator_sdk_controllers_execution_reconcile_seconds` | Timer | Time taken for reconciliations | -| `operator_sdk_controllers_execution_cleanup_seconds` | Timer | Time taken for cleanup operations | - -## Creating Grafana Dashboards - -### Example PromQL Queries - -**Reconciliation Rate:** -```promql -sum(rate(operator_sdk_reconciliations_started_total[5m])) by (controller) -``` - -**Success Rate:** -```promql -sum(rate(operator_sdk_reconciliations_success_total[5m])) / -sum(rate(operator_sdk_reconciliations_started_total[5m])) -``` - -**Error Rate:** -```promql -sum(rate(operator_sdk_reconciliations_failed_total[5m])) by (controller, exception) -``` - -**Queue Size:** -```promql -operator_sdk_reconciliations_queue_size -``` - -**Average Reconciliation Duration:** -```promql -rate(operator_sdk_controllers_execution_reconcile_seconds_sum[5m]) / -rate(operator_sdk_controllers_execution_reconcile_seconds_count[5m]) -``` - -### Sample Dashboard Configuration - -1. Open Grafana (http://localhost:3000) -2. Go to "Dashboards" → "New Dashboard" -3. Add panels with the PromQL queries above -4. Configure visualization types: - - Time series for rates and durations - - Gauge for queue size - - Stat for current values - -## Troubleshooting - -### Check Pod Status -```bash -kubectl get pods -n observability -``` - -### Check OpenTelemetry Collector Logs -```bash -kubectl logs -n observability -l app.kubernetes.io/name=otel-collector -f -``` - -### Check Prometheus Targets -```bash -kubectl port-forward -n observability svc/kube-prometheus-stack-prometheus 9090:9090 -``` -Then open http://localhost:9090/targets - -### Verify Metrics are Being Collected -```bash -# Check if OpenTelemetry is receiving metrics -kubectl port-forward -n observability svc/otel-collector-prometheus 8889:8889 -curl http://localhost:8889/metrics | grep operator_sdk -``` - -### Test OTLP Endpoint -```bash -# Port forward the OTLP HTTP endpoint -kubectl port-forward -n observability svc/otel-collector-collector 4318:4318 - -# Send a test metric (requires curl and valid OTLP JSON) -# This is just for testing connectivity -curl -X POST http://localhost:4318/v1/metrics \ - -H "Content-Type: application/json" \ - -d '{"resourceMetrics":[]}' -``` - -## Uninstalling - -To remove all components: - -```bash -# Delete OpenTelemetry resources -kubectl delete -n observability OpenTelemetryCollector otel-collector - -# Uninstall Helm releases -helm uninstall -n observability kube-prometheus-stack -helm uninstall -n observability opentelemetry-operator -helm uninstall -n cert-manager cert-manager - -# Delete namespaces -kubectl delete namespace observability cert-manager -``` - -## References - -- [JOSDK Observability Documentation](https://javaoperatorsdk.io/docs/documentation/observability/) -- [OpenTelemetry Java Documentation](https://opentelemetry.io/docs/instrumentation/java/) -- [Prometheus Operator](https://github.com/prometheus-operator/prometheus-operator) -- [Grafana Documentation](https://grafana.com/docs/) -- [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) diff --git a/grafana/install-observability.sh b/grafana/install-observability.sh deleted file mode 100755 index 63bdcb706f..0000000000 --- a/grafana/install-observability.sh +++ /dev/null @@ -1,264 +0,0 @@ -#!/bin/bash -# -# 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. -# - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -echo -e "${GREEN}========================================${NC}" -echo -e "${GREEN}Installing Observability Stack${NC}" -echo -e "${GREEN}OpenTelemetry + Prometheus + Grafana${NC}" -echo -e "${GREEN}========================================${NC}" - -# Check if minikube is running -echo -e "\n${YELLOW}Checking minikube status...${NC}" -if ! minikube status > /dev/null 2>&1; then - echo -e "${RED}Error: minikube is not running${NC}" - echo "Please start minikube with: minikube start" - exit 1 -fi -echo -e "${GREEN}✓ minikube is running${NC}" - -# Check if helm is installed -echo -e "\n${YELLOW}Checking helm installation...${NC}" -if ! command -v helm &> /dev/null; then - echo -e "${RED}Error: helm is not installed${NC}" - echo "Please install helm: https://helm.sh/docs/intro/install/" - exit 1 -fi -echo -e "${GREEN}✓ helm is installed${NC}" - -# Add Helm repositories -echo -e "\n${YELLOW}Adding Helm repositories...${NC}" -helm repo add jetstack https://charts.jetstack.io -helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts -helm repo add prometheus-community https://prometheus-community.github.io/helm-charts -helm repo update -echo -e "${GREEN}✓ Helm repositories added${NC}" - -# Install cert-manager (required for OpenTelemetry Operator) -echo -e "\n${YELLOW}Installing cert-manager...${NC}" -if kubectl get namespace cert-manager > /dev/null 2>&1; then - echo -e "${YELLOW}cert-manager namespace already exists, skipping...${NC}" -else - kubectl create namespace cert-manager - helm install cert-manager jetstack/cert-manager \ - --namespace cert-manager \ - --set crds.enabled=true \ - --wait - echo -e "${GREEN}✓ cert-manager installed${NC}" -fi - -# Create observability namespace -echo -e "\n${YELLOW}Creating observability namespace...${NC}" -kubectl create namespace observability --dry-run=client -o yaml | kubectl apply -f - -echo -e "${GREEN}✓ observability namespace ready${NC}" - -# Install OpenTelemetry Operator -echo -e "\n${YELLOW}Installing OpenTelemetry Operator...${NC}" -if helm list -n observability | grep -q opentelemetry-operator; then - echo -e "${YELLOW}OpenTelemetry Operator already installed, upgrading...${NC}" - helm upgrade opentelemetry-operator open-telemetry/opentelemetry-operator \ - --namespace observability \ - --set "manager.collectorImage.repository=otel/opentelemetry-collector-contrib" \ - --wait -else - helm install opentelemetry-operator open-telemetry/opentelemetry-operator \ - --namespace observability \ - --set "manager.collectorImage.repository=otel/opentelemetry-collector-contrib" \ - --wait -fi -echo -e "${GREEN}✓ OpenTelemetry Operator installed${NC}" - -# Install kube-prometheus-stack (includes Prometheus + Grafana) -echo -e "\n${YELLOW}Installing Prometheus and Grafana stack...${NC}" -if helm list -n observability | grep -q kube-prometheus-stack; then - echo -e "${YELLOW}kube-prometheus-stack already installed, upgrading...${NC}" - helm upgrade kube-prometheus-stack prometheus-community/kube-prometheus-stack \ - --namespace observability \ - --set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false \ - --set prometheus.prometheusSpec.podMonitorSelectorNilUsesHelmValues=false \ - --set grafana.adminPassword=admin \ - --wait -else - helm install kube-prometheus-stack prometheus-community/kube-prometheus-stack \ - --namespace observability \ - --set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false \ - --set prometheus.prometheusSpec.podMonitorSelectorNilUsesHelmValues=false \ - --set grafana.adminPassword=admin \ - --wait -fi -echo -e "${GREEN}✓ Prometheus and Grafana installed${NC}" - -# Create OpenTelemetry Collector instance -echo -e "\n${YELLOW}Creating OpenTelemetry Collector...${NC}" -kubectl apply -f - < Date: Mon, 9 Feb 2026 09:16:51 +0100 Subject: [PATCH 28/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- observability/install-observability.sh | 41 ++++++++++++++++--- .../operator/sample/WebPageOperator.java | 21 +++++++++- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/observability/install-observability.sh b/observability/install-observability.sh index 314ee1e4aa..2c81f2bf38 100755 --- a/observability/install-observability.sh +++ b/observability/install-observability.sh @@ -199,6 +199,36 @@ echo -e "\n${YELLOW}Waiting for all pods to be ready...${NC}" kubectl wait --for=condition=ready pod --all -n observability --timeout=300s echo -e "${GREEN}✓ All pods are ready${NC}" +# Import Grafana dashboards +echo -e "\n${YELLOW}Importing Grafana dashboards...${NC}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ -f "$SCRIPT_DIR/jvm-metrics-dashboard.json" ]; then + kubectl create configmap jvm-metrics-dashboard \ + --from-file="$SCRIPT_DIR/jvm-metrics-dashboard.json" \ + -n observability \ + --dry-run=client -o yaml | \ + kubectl label --dry-run=client --local -f - grafana_dashboard=1 -o yaml | \ + kubectl apply -f - + echo -e "${GREEN}✓ JVM Metrics dashboard imported${NC}" +else + echo -e "${YELLOW}⚠ JVM Metrics dashboard not found at $SCRIPT_DIR/jvm-metrics-dashboard.json${NC}" +fi + +if [ -f "$SCRIPT_DIR/josdk-operator-metrics-dashboard.json" ]; then + kubectl create configmap josdk-operator-metrics-dashboard \ + --from-file="$SCRIPT_DIR/josdk-operator-metrics-dashboard.json" \ + -n observability \ + --dry-run=client -o yaml | \ + kubectl label --dry-run=client --local -f - grafana_dashboard=1 -o yaml | \ + kubectl apply -f - + echo -e "${GREEN}✓ JOSDK Operator Metrics dashboard imported${NC}" +else + echo -e "${YELLOW}⚠ JOSDK Operator Metrics dashboard not found at $SCRIPT_DIR/josdk-operator-metrics-dashboard.json${NC}" +fi + +echo -e "${GREEN}✓ Dashboards will be available in Grafana shortly${NC}" + # Get pod statuses echo -e "\n${GREEN}========================================${NC}" echo -e "${GREEN}Installation Complete!${NC}" @@ -237,16 +267,17 @@ echo -e " ${GREEN}OTEL_TRACES_EXPORTER=otlp${NC}" echo -e "\n${GREEN}========================================${NC}" echo -e "${GREEN}Grafana Dashboards${NC}" echo -e "${GREEN}========================================${NC}" -echo -e "\nPre-installed dashboards in Grafana:" +echo -e "\nAutomatically imported dashboards:" +echo -e " - ${GREEN}JOSDK - JVM Metrics${NC} - Java Virtual Machine health and performance" +echo -e " - ${GREEN}JOSDK - Operator Metrics${NC} - Kubernetes operator performance and reconciliation" +echo -e "\nPre-installed Kubernetes dashboards:" echo -e " - Kubernetes / Compute Resources / Cluster" echo -e " - Kubernetes / Compute Resources / Namespace (Pods)" echo -e " - Node Exporter / Nodes" -echo -e "\nFor JOSDK metrics, create a custom dashboard with queries like:" -echo -e " ${GREEN}sum(rate(operator_sdk_reconciliations_started_total[5m]))${NC}" -echo -e " ${GREEN}sum(rate(operator_sdk_reconciliations_success_total[5m]))${NC}" -echo -e " ${GREEN}sum(rate(operator_sdk_reconciliations_failed_total[5m]))${NC}" +echo -e "\n${YELLOW}Note:${NC} Dashboards may take 30-60 seconds to appear in Grafana after installation." echo -e "\n${YELLOW}To uninstall:${NC}" +echo -e " kubectl delete configmap -n observability jvm-metrics-dashboard josdk-operator-metrics-dashboard" echo -e " kubectl delete -n observability OpenTelemetryCollector otel-collector" echo -e " helm uninstall -n observability kube-prometheus-stack" echo -e " helm uninstall -n observability opentelemetry-operator" diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java index 78c05f8df7..a2c342dc5e 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java @@ -33,6 +33,12 @@ import io.javaoperatorsdk.operator.sample.probes.StartupHandler; import io.micrometer.core.instrument.Clock; import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.jvm.ClassLoaderMetrics; +import io.micrometer.core.instrument.binder.jvm.JvmGcMetrics; +import io.micrometer.core.instrument.binder.jvm.JvmMemoryMetrics; +import io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics; +import io.micrometer.core.instrument.binder.system.ProcessorMetrics; +import io.micrometer.core.instrument.binder.system.UptimeMetrics; import io.micrometer.registry.otlp.OtlpConfig; import io.micrometer.registry.otlp.OtlpMeterRegistry; @@ -78,7 +84,20 @@ public static void main(String[] args) throws IOException { OtlpConfig otlpConfig = configProperties::get; MeterRegistry registry = new OtlpMeterRegistry(otlpConfig, Clock.SYSTEM); - return MicrometerMetrics.withoutPerResourceMetrics(registry); + + // Register JVM and system metrics + log.info("Registering JVM and system metrics..."); + new JvmMemoryMetrics().bindTo(registry); + new JvmGcMetrics().bindTo(registry); + new JvmThreadMetrics().bindTo(registry); + new ClassLoaderMetrics().bindTo(registry); + new ProcessorMetrics().bindTo(registry); + new UptimeMetrics().bindTo(registry); + log.info("JVM and system metrics registered"); + + return MicrometerMetrics.newPerResourceCollectingMicrometerMetricsBuilder(registry) + .collectingMetricsPerResource() + .build(); } @SuppressWarnings("unchecked") From 899e34564857f872d7e257f65c128a110412b153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 9 Feb 2026 09:27:30 +0100 Subject: [PATCH 29/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- observability/README.md | 246 ++++ .../josdk-operator-metrics-dashboard.json | 1109 +++++++++++++++++ observability/jvm-metrics-dashboard.json | 857 +++++++++++++ 3 files changed, 2212 insertions(+) create mode 100644 observability/README.md create mode 100644 observability/josdk-operator-metrics-dashboard.json create mode 100644 observability/jvm-metrics-dashboard.json diff --git a/observability/README.md b/observability/README.md new file mode 100644 index 0000000000..9706a466e9 --- /dev/null +++ b/observability/README.md @@ -0,0 +1,246 @@ +# Observability Stack for Java Operator SDK + +This directory contains the setup scripts and Grafana dashboards for monitoring Java Operator SDK applications. + +## Installation + +Run the installation script to deploy the full observability stack (OpenTelemetry Collector, Prometheus, and Grafana): + +```bash +./install-observability.sh +``` + +This will install: +- **cert-manager** - Required for OpenTelemetry Operator +- **OpenTelemetry Operator** - Manages OpenTelemetry Collector instances +- **OpenTelemetry Collector** - Receives OTLP metrics and exports to Prometheus +- **Prometheus** - Metrics storage and querying +- **Grafana** - Metrics visualization + +## Accessing Services + +### Grafana +```bash +kubectl port-forward -n observability svc/kube-prometheus-stack-grafana 3000:80 +``` +Then open http://localhost:3000 +- Username: `admin` +- Password: `admin` + +### Prometheus +```bash +kubectl port-forward -n observability svc/kube-prometheus-stack-prometheus 9090:9090 +``` +Then open http://localhost:9090 + +## Grafana Dashboards + +Two pre-configured dashboards are **automatically imported** during installation: + +### 1. JVM Metrics Dashboard (`jvm-metrics-dashboard.json`) + +Monitors Java Virtual Machine health and performance: + +**Panels:** +- **JVM Memory Used** - Heap and non-heap memory consumption by memory pool +- **JVM Threads** - Live, daemon, and peak thread counts +- **GC Pause Time Rate** - Garbage collection pause duration +- **GC Pause Count Rate** - Frequency of garbage collection events +- **CPU Usage** - System CPU utilization percentage +- **Classes Loaded** - Number of classes currently loaded +- **Process Uptime** - Application uptime in seconds +- **CPU Count** - Available processor cores +- **GC Memory Allocation Rate** - Memory allocation and promotion rates +- **Heap Memory Max vs Committed** - Heap memory limits and commitments + +**Key Metrics:** +- `jvm.memory.used`, `jvm.memory.max`, `jvm.memory.committed` +- `jvm.gc.pause`, `jvm.gc.memory.allocated`, `jvm.gc.memory.promoted` +- `jvm.threads.live`, `jvm.threads.daemon`, `jvm.threads.peak` +- `jvm.classes.loaded`, `jvm.classes.unloaded` +- `system.cpu.usage`, `system.cpu.count` +- `process.uptime` + +### 2. Java Operator SDK Metrics Dashboard (`josdk-operator-metrics-dashboard.json`) + +Monitors Kubernetes operator performance and health: + +**Panels:** +- **Reconciliation Rate (Started)** - Rate of reconciliation loops triggered +- **Reconciliation Success vs Failure Rate** - Success/failure ratio over time +- **Currently Executing Reconciliations** - Active reconciliation threads +- **Reconciliation Queue Size** - Pending reconciliation work +- **Total Reconciliations** - Cumulative count of reconciliations +- **Error Rate** - Overall error rate across all reconciliations +- **Reconciliation Execution Time** - P50, P95, P99 latency percentiles +- **Event Reception Rate** - Kubernetes event processing rate +- **Failures by Exception Type** - Breakdown of errors by exception class +- **Controller Execution Success vs Failure** - Controller-level success metrics +- **Delete Event Rate** - Resource deletion event frequency +- **Reconciliation Retry Rate** - Retry attempts and patterns + +**Key Metrics:** +- `operator.sdk.reconciliations.started`, `.success`, `.failed` +- `operator.sdk.reconciliations.executions` - Current execution count +- `operator.sdk.reconciliations.queue.size` - Queue depth +- `operator.sdk.controllers.execution.reconcile` - Execution timing histograms +- `operator.sdk.events.received`, `.delete` - Event reception +- Retry metrics and failure breakdowns + +## Importing Dashboards into Grafana + +### Automatic Import (Default) + +The dashboards are **automatically imported** when you run `./install-observability.sh`. They will appear in Grafana within 30-60 seconds after installation. No manual steps required! + +To verify the dashboards were imported: +1. Access Grafana at http://localhost:3000 +2. Navigate to **Dashboards** → **Browse** +3. Look for "JOSDK - JVM Metrics" and "JOSDK - Operator Metrics" + +### Manual Import Methods + +If you need to re-import or update the dashboards manually: + +#### Method 1: Via Grafana UI + +1. Access Grafana at http://localhost:3000 +2. Login with admin/admin +3. Navigate to **Dashboards** → **Import** +4. Click **Upload JSON file** +5. Select `jvm-metrics-dashboard.json` or `josdk-operator-metrics-dashboard.json` +6. Select **Prometheus** as the data source +7. Click **Import** + +#### Method 2: Via kubectl ConfigMap + +```bash +# Re-import JVM dashboard +kubectl create configmap jvm-metrics-dashboard \ + --from-file=jvm-metrics-dashboard.json \ + -n observability \ + -o yaml --dry-run=client | \ + kubectl label --dry-run=client --local -f - grafana_dashboard=1 -o yaml | \ + kubectl apply -f - + +# Re-import Operator dashboard +kubectl create configmap josdk-operator-metrics-dashboard \ + --from-file=josdk-operator-metrics-dashboard.json \ + -n observability \ + -o yaml --dry-run=client | \ + kubectl label --dry-run=client --local -f - grafana_dashboard=1 -o yaml | \ + kubectl apply -f - +``` + +The dashboards will be automatically discovered and loaded by Grafana within 30-60 seconds. + +## Configuring Your Operator + +To enable metrics export from your JOSDK operator, ensure your application: + +1. **Has the required dependency** (already included in webpage sample): + ```xml + + io.micrometer + micrometer-registry-otlp + + ``` + +2. **Configures OTLP export** via `otlp-config.yaml`: + ```yaml + otlp: + url: "http://otel-collector-collector.observability.svc.cluster.local:4318/v1/metrics" + step: 15s + batchSize: 15000 + aggregationTemporality: "cumulative" + ``` + +3. **Registers JVM and JOSDK metrics** (see `WebPageOperator.java` for reference implementation) + +## OTLP Endpoints + +The OpenTelemetry Collector provides the following endpoints: + +- **OTLP gRPC**: `otel-collector-collector.observability.svc.cluster.local:4317` +- **OTLP HTTP**: `otel-collector-collector.observability.svc.cluster.local:4318` +- **Prometheus Scrape**: `http://otel-collector-prometheus.observability.svc.cluster.local:8889/metrics` + +## Troubleshooting + +### Check OpenTelemetry Collector Logs +```bash +kubectl logs -n observability -l app.kubernetes.io/name=otel-collector -f +``` + +### Check Prometheus Targets +```bash +kubectl port-forward -n observability svc/kube-prometheus-stack-prometheus 9090:9090 +``` +Open http://localhost:9090/targets and verify the OTLP collector target is UP. + +### Verify Metrics in Prometheus +Open Prometheus UI and search for metrics: +- JVM metrics: `otel_jvm_*` +- Operator metrics: `otel_operator_sdk_*` + +### Check Grafana Data Source +1. Navigate to **Configuration** → **Data Sources** +2. Verify Prometheus data source is configured and working +3. Click **Test** to verify connectivity + +## Uninstalling + +To remove the observability stack: + +```bash +kubectl delete configmap -n observability jvm-metrics-dashboard josdk-operator-metrics-dashboard +kubectl delete -n observability OpenTelemetryCollector otel-collector +helm uninstall -n observability kube-prometheus-stack +helm uninstall -n observability opentelemetry-operator +helm uninstall -n cert-manager cert-manager +kubectl delete namespace observability cert-manager +``` + +## Customizing Dashboards + +The dashboard JSON files can be modified to: +- Add new panels for custom metrics +- Adjust time ranges and refresh intervals +- Change visualization types +- Add templating variables for filtering +- Modify alert thresholds + +After making changes, re-import the dashboard using one of the methods above. + +## Example Queries + +### JVM Metrics +```promql +# Heap memory usage percentage +(otel_jvm_memory_used_bytes{area="heap"} / otel_jvm_memory_max_bytes{area="heap"}) * 100 + +# GC throughput (percentage of time NOT in GC) +100 - (rate(otel_jvm_gc_pause_seconds_sum[5m]) * 100) + +# Thread count trend +otel_jvm_threads_live_threads +``` + +### Operator Metrics +```promql +# Reconciliation success rate +rate(otel_operator_sdk_reconciliations_success_total[5m]) / rate(otel_operator_sdk_reconciliations_started_total[5m]) + +# Average reconciliation time +rate(otel_operator_sdk_controllers_execution_reconcile_seconds_sum[5m]) / rate(otel_operator_sdk_controllers_execution_reconcile_seconds_count[5m]) + +# Queue saturation +otel_operator_sdk_reconciliations_queue_size / on() group_left() max(otel_operator_sdk_reconciliations_queue_size) +``` + +## References + +- [Java Operator SDK Documentation](https://javaoperatorsdk.io) +- [Micrometer OTLP Documentation](https://micrometer.io/docs/registry/otlp) +- [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) +- [Grafana Dashboards](https://grafana.com/docs/grafana/latest/dashboards/) diff --git a/observability/josdk-operator-metrics-dashboard.json b/observability/josdk-operator-metrics-dashboard.json new file mode 100644 index 0000000000..006821a467 --- /dev/null +++ b/observability/josdk-operator-metrics-dashboard.json @@ -0,0 +1,1109 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Rate of reconciliations started per second", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["last", "mean"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(otel_operator_sdk_reconciliations_started_total{job=\"webpage-operator\"}[5m])) by (kind, version)", + "legendFormat": "{{kind}} ({{version}})", + "range": true, + "refId": "A" + } + ], + "title": "Reconciliation Rate (Started)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Success vs Failure rate of reconciliations", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Success" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Failure" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": ["last", "mean"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(otel_operator_sdk_reconciliations_success_total{job=\"webpage-operator\"}[5m]))", + "legendFormat": "Success", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(otel_operator_sdk_reconciliations_failed_total{job=\"webpage-operator\"}[5m]))", + "legendFormat": "Failure", + "range": true, + "refId": "B" + } + ], + "title": "Reconciliation Success vs Failure Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Current number of reconciliations being executed", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 10 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(otel_operator_sdk_reconciliations_executions{job=\"webpage-operator\"})", + "legendFormat": "Executing", + "range": true, + "refId": "A" + } + ], + "title": "Currently Executing Reconciliations", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Current reconciliation queue size", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 10 + }, + { + "color": "red", + "value": 50 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 6, + "y": 8 + }, + "id": 4, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(otel_operator_sdk_reconciliations_queue_size{job=\"webpage-operator\"})", + "legendFormat": "Queue Size", + "range": true, + "refId": "A" + } + ], + "title": "Reconciliation Queue Size", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Total reconciliations started", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 12, + "y": 8 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(otel_operator_sdk_reconciliations_started_total{job=\"webpage-operator\"})", + "legendFormat": "Total", + "range": true, + "refId": "A" + } + ], + "title": "Total Reconciliations", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Error rate by exception type", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 18, + "y": 8 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(otel_operator_sdk_reconciliations_failed_total{job=\"webpage-operator\"}[5m]))", + "legendFormat": "Error Rate", + "range": true, + "refId": "A" + } + ], + "title": "Error Rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Controller execution time percentiles", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 7, + "options": { + "legend": { + "calcs": ["last", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(otel_operator_sdk_controllers_execution_reconcile_seconds_bucket{job=\"webpage-operator\"}[5m])) by (le, controller))", + "legendFormat": "p50 - {{controller}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum(rate(otel_operator_sdk_controllers_execution_reconcile_seconds_bucket{job=\"webpage-operator\"}[5m])) by (le, controller))", + "legendFormat": "p95 - {{controller}}", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.99, sum(rate(otel_operator_sdk_controllers_execution_reconcile_seconds_bucket{job=\"webpage-operator\"}[5m])) by (le, controller))", + "legendFormat": "p99 - {{controller}}", + "range": true, + "refId": "C" + } + ], + "title": "Reconciliation Execution Time (Percentiles)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Rate of events received by the operator", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 8, + "options": { + "legend": { + "calcs": ["last", "mean"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(otel_operator_sdk_events_received_total{job=\"webpage-operator\"}[5m])) by (event, action)", + "legendFormat": "{{event}} - {{action}}", + "range": true, + "refId": "A" + } + ], + "title": "Event Reception Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Failures by exception type", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 9, + "options": { + "legend": { + "calcs": ["last", "sum"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(otel_operator_sdk_reconciliations_failed_total{job=\"webpage-operator\"}[5m])) by (exception)", + "legendFormat": "{{exception}}", + "range": true, + "refId": "A" + } + ], + "title": "Failures by Exception Type", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Controller execution success vs failure", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 10, + "options": { + "legend": { + "calcs": ["last", "mean"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(otel_operator_sdk_controllers_execution_reconcile_success_total{job=\"webpage-operator\"}[5m])) by (type)", + "legendFormat": "Success - {{type}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(otel_operator_sdk_controllers_execution_reconcile_failure_total{job=\"webpage-operator\"}[5m])) by (exception)", + "legendFormat": "Failure - {{exception}}", + "range": true, + "refId": "B" + } + ], + "title": "Controller Execution Success vs Failure", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Rate of delete events received", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 32 + }, + "id": 11, + "options": { + "legend": { + "calcs": ["last", "sum"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(otel_operator_sdk_events_delete_total{job=\"webpage-operator\"}[5m])) by (kind, version)", + "legendFormat": "{{kind}} ({{version}})", + "range": true, + "refId": "A" + } + ], + "title": "Delete Event Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Reconciliation retry information", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 32 + }, + "id": 12, + "options": { + "legend": { + "calcs": ["last", "mean"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(otel_operator_sdk_reconciliations_started_total{job=\"webpage-operator\", operator_sdk_reconciliations_retries_last=\"true\"}[5m]))", + "legendFormat": "Last Retry Attempts", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(rate(otel_operator_sdk_reconciliations_started_total{job=\"webpage-operator\", operator_sdk_reconciliations_retries_last=\"false\"}[5m]))", + "legendFormat": "Retries (Not Last)", + "range": true, + "refId": "B" + } + ], + "title": "Reconciliation Retry Rate", + "type": "timeseries" + } + ], + "refresh": "10s", + "schemaVersion": 38, + "style": "dark", + "tags": ["operator", "kubernetes", "josdk"], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "JOSDK - Operator Metrics", + "uid": "josdk-operator-metrics", + "version": 0, + "weekStart": "" +} diff --git a/observability/jvm-metrics-dashboard.json b/observability/jvm-metrics-dashboard.json new file mode 100644 index 0000000000..0a817aa09c --- /dev/null +++ b/observability/jvm-metrics-dashboard.json @@ -0,0 +1,857 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["last", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "otel_jvm_memory_used_bytes{job=\"webpage-operator\"}", + "legendFormat": "{{area}} - {{id}}", + "range": true, + "refId": "A" + } + ], + "title": "JVM Memory Used", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": ["last", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "otel_jvm_threads_live_threads{job=\"webpage-operator\"}", + "legendFormat": "Live Threads", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "otel_jvm_threads_daemon_threads{job=\"webpage-operator\"}", + "legendFormat": "Daemon Threads", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "otel_jvm_threads_peak_threads{job=\"webpage-operator\"}", + "legendFormat": "Peak Threads", + "range": true, + "refId": "C" + } + ], + "title": "JVM Threads", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "calcs": ["last", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(otel_jvm_gc_pause_seconds_sum{job=\"webpage-operator\"}[5m])", + "legendFormat": "{{action}} - {{cause}}", + "range": true, + "refId": "A" + } + ], + "title": "GC Pause Time Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": ["last"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(otel_jvm_gc_pause_seconds_count{job=\"webpage-operator\"}[5m])", + "legendFormat": "{{action}} - {{cause}}", + "range": true, + "refId": "A" + } + ], + "title": "GC Pause Count Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "otel_system_cpu_usage{job=\"webpage-operator\"}", + "legendFormat": "CPU Usage", + "range": true, + "refId": "A" + } + ], + "title": "CPU Usage", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 6, + "y": 16 + }, + "id": 6, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "otel_jvm_classes_loaded_classes{job=\"webpage-operator\"}", + "legendFormat": "Classes Loaded", + "range": true, + "refId": "A" + } + ], + "title": "Classes Loaded", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 12, + "y": 16 + }, + "id": 7, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "otel_process_uptime_seconds{job=\"webpage-operator\"}", + "legendFormat": "Uptime", + "range": true, + "refId": "A" + } + ], + "title": "Process Uptime", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 18, + "y": 16 + }, + "id": 8, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "otel_system_cpu_count{job=\"webpage-operator\"}", + "legendFormat": "CPU Count", + "range": true, + "refId": "A" + } + ], + "title": "CPU Count", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 9, + "options": { + "legend": { + "calcs": ["last"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(otel_jvm_gc_memory_allocated_bytes_total{job=\"webpage-operator\"}[5m])", + "legendFormat": "Allocated", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(otel_jvm_gc_memory_promoted_bytes_total{job=\"webpage-operator\"}[5m])", + "legendFormat": "Promoted", + "range": true, + "refId": "B" + } + ], + "title": "GC Memory Allocation Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 10, + "options": { + "legend": { + "calcs": ["last", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "otel_jvm_memory_max_bytes{job=\"webpage-operator\", area=\"heap\"}", + "legendFormat": "Max Heap", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "otel_jvm_memory_committed_bytes{job=\"webpage-operator\", area=\"heap\"}", + "legendFormat": "Committed Heap", + "range": true, + "refId": "B" + } + ], + "title": "Heap Memory Max vs Committed", + "type": "timeseries" + } + ], + "refresh": "10s", + "schemaVersion": 38, + "style": "dark", + "tags": ["jvm", "java", "josdk"], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "JOSDK - JVM Metrics", + "uid": "josdk-jvm-metrics", + "version": 0, + "weekStart": "" +} From ff05901f8a5089229a28b1ad70288ff69b044bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 9 Feb 2026 10:06:15 +0100 Subject: [PATCH 30/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- observability/install-observability.sh | 45 +++++++++++++------ .../operator/sample/WebPageOperator.java | 4 +- .../webpage/src/main/resources/log4j2.xml | 2 +- .../src/main/resources/otlp-config.yaml | 3 +- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/observability/install-observability.sh b/observability/install-observability.sh index 2c81f2bf38..017e9adf86 100755 --- a/observability/install-observability.sh +++ b/observability/install-observability.sh @@ -45,6 +45,17 @@ helm repo add prometheus-community https://prometheus-community.github.io/helm-c helm repo update echo -e "${GREEN}✓ Helm repositories added${NC}" +echo -e "\n${GREEN}========================================${NC}" +echo -e "${GREEN}Installing Components (Parallel)${NC}" +echo -e "${GREEN}========================================${NC}" +echo -e "The following will be installed:" +echo -e " • cert-manager" +echo -e " • OpenTelemetry Operator" +echo -e " • Prometheus & Grafana" +echo -e " • OpenTelemetry Collector" +echo -e " • Service Monitors" +echo -e "\n${YELLOW}All resources will be applied first, then we'll wait for them to become ready.${NC}\n" + # Install cert-manager (required for OpenTelemetry Operator) echo -e "\n${YELLOW}Installing cert-manager...${NC}" if kubectl get namespace cert-manager > /dev/null 2>&1; then @@ -53,9 +64,8 @@ else kubectl create namespace cert-manager helm install cert-manager jetstack/cert-manager \ --namespace cert-manager \ - --set crds.enabled=true \ - --wait - echo -e "${GREEN}✓ cert-manager installed${NC}" + --set crds.enabled=true + echo -e "${GREEN}✓ cert-manager installation started${NC}" fi # Create observability namespace @@ -70,15 +80,13 @@ if helm list -n observability | grep -q opentelemetry-operator; then echo -e "${YELLOW}OpenTelemetry Operator already installed, upgrading...${NC}" helm upgrade opentelemetry-operator open-telemetry/opentelemetry-operator \ --namespace observability \ - --set "manager.collectorImage.repository=otel/opentelemetry-collector-contrib" \ - --wait + --set "manager.collectorImage.repository=otel/opentelemetry-collector-contrib" else helm install opentelemetry-operator open-telemetry/opentelemetry-operator \ --namespace observability \ - --set "manager.collectorImage.repository=otel/opentelemetry-collector-contrib" \ - --wait + --set "manager.collectorImage.repository=otel/opentelemetry-collector-contrib" fi -echo -e "${GREEN}✓ OpenTelemetry Operator installed${NC}" +echo -e "${GREEN}✓ OpenTelemetry Operator installation started${NC}" # Install kube-prometheus-stack (includes Prometheus + Grafana) echo -e "\n${YELLOW}Installing Prometheus and Grafana stack...${NC}" @@ -88,17 +96,15 @@ if helm list -n observability | grep -q kube-prometheus-stack; then --namespace observability \ --set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false \ --set prometheus.prometheusSpec.podMonitorSelectorNilUsesHelmValues=false \ - --set grafana.adminPassword=admin \ - --wait + --set grafana.adminPassword=admin else helm install kube-prometheus-stack prometheus-community/kube-prometheus-stack \ --namespace observability \ --set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false \ --set prometheus.prometheusSpec.podMonitorSelectorNilUsesHelmValues=false \ - --set grafana.adminPassword=admin \ - --wait + --set grafana.adminPassword=admin fi -echo -e "${GREEN}✓ Prometheus and Grafana installed${NC}" +echo -e "${GREEN}✓ Prometheus and Grafana installation started${NC}" # Create OpenTelemetry Collector instance echo -e "\n${YELLOW}Creating OpenTelemetry Collector...${NC}" @@ -195,8 +201,19 @@ EOF echo -e "${GREEN}✓ ServiceMonitor created${NC}" # Wait for all pods to be ready -echo -e "\n${YELLOW}Waiting for all pods to be ready...${NC}" +echo -e "\n${GREEN}========================================${NC}" +echo -e "${GREEN}All resources have been applied!${NC}" +echo -e "${GREEN}========================================${NC}" +echo -e "\n${YELLOW}Waiting for all pods to become ready (this may take 2-3 minutes)...${NC}" + +# Wait for cert-manager pods +echo -e "${YELLOW}Checking cert-manager pods...${NC}" +kubectl wait --for=condition=ready pod --all -n cert-manager --timeout=300s 2>/dev/null || echo -e "${YELLOW}cert-manager already running or skipped${NC}" + +# Wait for observability pods +echo -e "${YELLOW}Checking observability pods...${NC}" kubectl wait --for=condition=ready pod --all -n observability --timeout=300s + echo -e "${GREEN}✓ All pods are ready${NC}" # Import Grafana dashboards diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java index a2c342dc5e..dd1155eab3 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java @@ -61,6 +61,7 @@ public static void main(String[] args) throws IOException { Metrics metrics = initOTLPMetrics(); Operator operator = new Operator(o -> o.withStopOnInformerErrorDuringStartup(false).withMetrics(metrics)); + String reconcilerEnvVar = System.getenv(WEBPAGE_RECONCILER_ENV); if (WEBPAGE_CLASSIC_RECONCILER_ENV_VALUE.equals(reconcilerEnvVar)) { operator.register(new WebPageReconciler()); @@ -81,7 +82,7 @@ public static void main(String[] args) throws IOException { private static @NonNull Metrics initOTLPMetrics() { Map configProperties = loadConfigFromYaml(); - OtlpConfig otlpConfig = configProperties::get; + OtlpConfig otlpConfig = key -> configProperties.get(key); MeterRegistry registry = new OtlpMeterRegistry(otlpConfig, Clock.SYSTEM); @@ -93,7 +94,6 @@ public static void main(String[] args) throws IOException { new ClassLoaderMetrics().bindTo(registry); new ProcessorMetrics().bindTo(registry); new UptimeMetrics().bindTo(registry); - log.info("JVM and system metrics registered"); return MicrometerMetrics.newPerResourceCollectingMicrometerMetricsBuilder(registry) .collectingMetricsPerResource() diff --git a/sample-operators/webpage/src/main/resources/log4j2.xml b/sample-operators/webpage/src/main/resources/log4j2.xml index 0bf270c7e6..ebe273e40e 100644 --- a/sample-operators/webpage/src/main/resources/log4j2.xml +++ b/sample-operators/webpage/src/main/resources/log4j2.xml @@ -23,7 +23,7 @@ - + diff --git a/sample-operators/webpage/src/main/resources/otlp-config.yaml b/sample-operators/webpage/src/main/resources/otlp-config.yaml index ca93bfc965..17d773eb70 100644 --- a/sample-operators/webpage/src/main/resources/otlp-config.yaml +++ b/sample-operators/webpage/src/main/resources/otlp-config.yaml @@ -16,7 +16,8 @@ otlp: # OTLP Collector endpoint - see observability/install-observability.sh for setup - url: "http://otel-collector-collector.observability.svc.cluster.local:4318/v1/metrics" + url: "http://localhost:4318/v1/metrics" +# url: "http://otel-collector-collector.observability.svc.cluster.local:4318/v1/metrics" step: 15s batchSize: 15000 aggregationTemporality: "cumulative" From f7e2565189dc61e6499d7e23547d8ae9a25e2500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 9 Feb 2026 10:19:39 +0100 Subject: [PATCH 31/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- sample-operators/webpage/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sample-operators/webpage/README.md b/sample-operators/webpage/README.md index 7718d0f2f3..96329d18a9 100644 --- a/sample-operators/webpage/README.md +++ b/sample-operators/webpage/README.md @@ -76,3 +76,6 @@ of your choice. The JAR file is built using your local Maven and JDK and then co 1. Deploy the CRD: `kubectl apply -f target/classes/META-INF/fabric8/webpages.sample.javaoperatorsdk-v1.yml` 2. Deploy the operator: `kubectl apply -f k8s/operator.yaml` + +To install observability components - such as Prometheus, Open Telemetry, Grafana use - execute: +[install-observability.sh](../../observability/install-observability.sh) From 98f200f2b158d1d3f1e9c7fded43ef40cce3c6af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 9 Feb 2026 15:46:11 +0100 Subject: [PATCH 32/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- observability/install-observability.sh | 3 ++- sample-operators/webpage/pom.xml | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/observability/install-observability.sh b/observability/install-observability.sh index 017e9adf86..ea3a083eec 100755 --- a/observability/install-observability.sh +++ b/observability/install-observability.sh @@ -181,7 +181,7 @@ spec: targetPort: 8889 protocol: TCP selector: - app.kubernetes.io/name: otel-collector + app.kubernetes.io/name: otel-collector-collector --- apiVersion: monitoring.coreos.com/v1 kind: ServiceMonitor @@ -190,6 +190,7 @@ metadata: namespace: observability labels: app: otel-collector + release: kube-prometheus-stack spec: selector: matchLabels: diff --git a/sample-operators/webpage/pom.xml b/sample-operators/webpage/pom.xml index 97c885e403..10b0352605 100644 --- a/sample-operators/webpage/pom.xml +++ b/sample-operators/webpage/pom.xml @@ -39,6 +39,13 @@ pom import + + io.micrometer + micrometer-bom + ${micrometer-core.version} + pom + import + From 77307e23297cbc817f3e25bc32f0618ee6b69943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 9 Feb 2026 15:47:03 +0100 Subject: [PATCH 33/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- sample-operators/webpage/pom.xml | 14 +++++++------- .../operator/sample/WebPageOperator.java | 1 + 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/sample-operators/webpage/pom.xml b/sample-operators/webpage/pom.xml index 10b0352605..f8c79cf268 100644 --- a/sample-operators/webpage/pom.xml +++ b/sample-operators/webpage/pom.xml @@ -39,13 +39,13 @@ pom import - - io.micrometer - micrometer-bom - ${micrometer-core.version} - pom - import - + + io.micrometer + micrometer-bom + ${micrometer-core.version} + pom + import + diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java index dd1155eab3..837963f00a 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java @@ -57,6 +57,7 @@ public class WebPageOperator { public static void main(String[] args) throws IOException { log.info("WebServer Operator starting!"); + // TODO remove otel prefix, add job and additional labels?! // Load configuration from config.yaml Metrics metrics = initOTLPMetrics(); Operator operator = From 1daab47fa8dc6f99620f80482d9d0abfaa3a767e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 9 Feb 2026 16:18:30 +0100 Subject: [PATCH 34/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../java/io/javaoperatorsdk/operator/sample/WebPageOperator.java | 1 + 1 file changed, 1 insertion(+) diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java index 837963f00a..d92dfdd863 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java @@ -58,6 +58,7 @@ public static void main(String[] args) throws IOException { log.info("WebServer Operator starting!"); // TODO remove otel prefix, add job and additional labels?! + // TODO add test for checking if there are metrics in prometheus // Load configuration from config.yaml Metrics metrics = initOTLPMetrics(); Operator operator = From cefad784386786f79891ff849f72c5a58b94980e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 9 Feb 2026 16:59:54 +0100 Subject: [PATCH 35/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- observability/install-observability.sh | 3 ++- .../operator/sample/WebPageOperator.java | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/observability/install-observability.sh b/observability/install-observability.sh index ea3a083eec..e724ac54d4 100755 --- a/observability/install-observability.sh +++ b/observability/install-observability.sh @@ -143,7 +143,7 @@ spec: exporters: prometheus: endpoint: "0.0.0.0:8889" - namespace: "otel" + namespace: "" send_timestamps: true metric_expiration: 5m debug: @@ -192,6 +192,7 @@ metadata: app: otel-collector release: kube-prometheus-stack spec: + jobLabel: app selector: matchLabels: app: otel-collector diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java index d92dfdd863..e43a253511 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java @@ -22,6 +22,7 @@ import java.util.Map; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.yaml.snakeyaml.Yaml; @@ -84,7 +85,22 @@ public static void main(String[] args) throws IOException { private static @NonNull Metrics initOTLPMetrics() { Map configProperties = loadConfigFromYaml(); - OtlpConfig otlpConfig = key -> configProperties.get(key); + OtlpConfig otlpConfig = new OtlpConfig() { + @Override + public String prefix() { + return ""; + } + + @Override + public @Nullable String get(String key) { + return configProperties.get(key); + } + + @Override + public Map resourceAttributes() { + return Map.of("service.name","josdk","operator","webpage"); + } + }; MeterRegistry registry = new OtlpMeterRegistry(otlpConfig, Clock.SYSTEM); From b40766e992cc9b9a457c38f60b8cc5caf176ef1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 9 Feb 2026 18:42:54 +0100 Subject: [PATCH 36/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- observability/README.md | 22 +++++++---- observability/install-observability.sh | 2 + .../josdk-operator-metrics-dashboard.json | 34 ++++++++-------- observability/jvm-metrics-dashboard.json | 30 +++++++------- .../operator/sample/WebPageOperator.java | 39 +++++++++++-------- 5 files changed, 70 insertions(+), 57 deletions(-) diff --git a/observability/README.md b/observability/README.md index 9706a466e9..58caae27d0 100644 --- a/observability/README.md +++ b/observability/README.md @@ -61,6 +61,9 @@ Monitors Java Virtual Machine health and performance: - `system.cpu.usage`, `system.cpu.count` - `process.uptime` +**Filtering:** +All panels filter by `service_name="josdk"` to show metrics only from your operator. + ### 2. Java Operator SDK Metrics Dashboard (`josdk-operator-metrics-dashboard.json`) Monitors Kubernetes operator performance and health: @@ -87,6 +90,9 @@ Monitors Kubernetes operator performance and health: - `operator.sdk.events.received`, `.delete` - Event reception - Retry metrics and failure breakdowns +**Filtering:** +All panels filter by `service_name="josdk"` to show metrics only from your operator. + ## Importing Dashboards into Grafana ### Automatic Import (Default) @@ -180,8 +186,8 @@ Open http://localhost:9090/targets and verify the OTLP collector target is UP. ### Verify Metrics in Prometheus Open Prometheus UI and search for metrics: -- JVM metrics: `otel_jvm_*` -- Operator metrics: `otel_operator_sdk_*` +- JVM metrics: `jvm_*` +- Operator metrics: `operator_sdk_*` ### Check Grafana Data Source 1. Navigate to **Configuration** → **Data Sources** @@ -217,25 +223,25 @@ After making changes, re-import the dashboard using one of the methods above. ### JVM Metrics ```promql # Heap memory usage percentage -(otel_jvm_memory_used_bytes{area="heap"} / otel_jvm_memory_max_bytes{area="heap"}) * 100 +(jvm_memory_used_bytes{area="heap"} / jvm_memory_max_bytes{area="heap"}) * 100 # GC throughput (percentage of time NOT in GC) -100 - (rate(otel_jvm_gc_pause_seconds_sum[5m]) * 100) +100 - (rate(jvm_gc_pause_seconds_sum[5m]) * 100) # Thread count trend -otel_jvm_threads_live_threads +jvm_threads_live_threads ``` ### Operator Metrics ```promql # Reconciliation success rate -rate(otel_operator_sdk_reconciliations_success_total[5m]) / rate(otel_operator_sdk_reconciliations_started_total[5m]) +rate(operator_sdk_reconciliations_success_total[5m]) / rate(operator_sdk_reconciliations_started_total[5m]) # Average reconciliation time -rate(otel_operator_sdk_controllers_execution_reconcile_seconds_sum[5m]) / rate(otel_operator_sdk_controllers_execution_reconcile_seconds_count[5m]) +rate(operator_sdk_controllers_execution_reconcile_seconds_sum[5m]) / rate(operator_sdk_controllers_execution_reconcile_seconds_count[5m]) # Queue saturation -otel_operator_sdk_reconciliations_queue_size / on() group_left() max(otel_operator_sdk_reconciliations_queue_size) +operator_sdk_reconciliations_queue_size / on() group_left() max(operator_sdk_reconciliations_queue_size) ``` ## References diff --git a/observability/install-observability.sh b/observability/install-observability.sh index e724ac54d4..dc7430520b 100755 --- a/observability/install-observability.sh +++ b/observability/install-observability.sh @@ -146,6 +146,8 @@ spec: namespace: "" send_timestamps: true metric_expiration: 5m + resource_to_telemetry_conversion: + enabled: true debug: verbosity: detailed sampling_initial: 5 diff --git a/observability/josdk-operator-metrics-dashboard.json b/observability/josdk-operator-metrics-dashboard.json index 006821a467..6b53d26611 100644 --- a/observability/josdk-operator-metrics-dashboard.json +++ b/observability/josdk-operator-metrics-dashboard.json @@ -103,7 +103,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(otel_operator_sdk_reconciliations_started_total{job=\"webpage-operator\"}[5m])) by (kind, version)", + "expr": "sum(rate(operator_sdk_reconciliations_started_total{service_name=\"josdk\"}[5m])) by (kind, version)", "legendFormat": "{{kind}} ({{version}})", "range": true, "refId": "A" @@ -224,7 +224,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(otel_operator_sdk_reconciliations_success_total{job=\"webpage-operator\"}[5m]))", + "expr": "sum(rate(operator_sdk_reconciliations_success_total{service_name=\"josdk\"}[5m]))", "legendFormat": "Success", "range": true, "refId": "A" @@ -235,7 +235,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(otel_operator_sdk_reconciliations_failed_total{job=\"webpage-operator\"}[5m]))", + "expr": "sum(rate(operator_sdk_reconciliations_failed_total{service_name=\"josdk\"}[5m]))", "legendFormat": "Failure", "range": true, "refId": "B" @@ -302,7 +302,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(otel_operator_sdk_reconciliations_executions{job=\"webpage-operator\"})", + "expr": "sum(operator_sdk_reconciliations_executions{service_name=\"josdk\"})", "legendFormat": "Executing", "range": true, "refId": "A" @@ -369,7 +369,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(otel_operator_sdk_reconciliations_queue_size{job=\"webpage-operator\"})", + "expr": "sum(operator_sdk_reconciliations_queue_size{service_name=\"josdk\"})", "legendFormat": "Queue Size", "range": true, "refId": "A" @@ -430,7 +430,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(otel_operator_sdk_reconciliations_started_total{job=\"webpage-operator\"})", + "expr": "sum(operator_sdk_reconciliations_started_total{service_name=\"josdk\"})", "legendFormat": "Total", "range": true, "refId": "A" @@ -495,7 +495,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(otel_operator_sdk_reconciliations_failed_total{job=\"webpage-operator\"}[5m]))", + "expr": "sum(rate(operator_sdk_reconciliations_failed_total{service_name=\"josdk\"}[5m]))", "legendFormat": "Error Rate", "range": true, "refId": "A" @@ -585,7 +585,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "histogram_quantile(0.50, sum(rate(otel_operator_sdk_controllers_execution_reconcile_seconds_bucket{job=\"webpage-operator\"}[5m])) by (le, controller))", + "expr": "histogram_quantile(0.50, sum(rate(operator_sdk_controllers_execution_reconcile_seconds_bucket{service_name=\"josdk\"}[5m])) by (le, controller))", "legendFormat": "p50 - {{controller}}", "range": true, "refId": "A" @@ -596,7 +596,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "histogram_quantile(0.95, sum(rate(otel_operator_sdk_controllers_execution_reconcile_seconds_bucket{job=\"webpage-operator\"}[5m])) by (le, controller))", + "expr": "histogram_quantile(0.95, sum(rate(operator_sdk_controllers_execution_reconcile_seconds_bucket{service_name=\"josdk\"}[5m])) by (le, controller))", "legendFormat": "p95 - {{controller}}", "range": true, "refId": "B" @@ -607,7 +607,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "histogram_quantile(0.99, sum(rate(otel_operator_sdk_controllers_execution_reconcile_seconds_bucket{job=\"webpage-operator\"}[5m])) by (le, controller))", + "expr": "histogram_quantile(0.99, sum(rate(operator_sdk_controllers_execution_reconcile_seconds_bucket{service_name=\"josdk\"}[5m])) by (le, controller))", "legendFormat": "p99 - {{controller}}", "range": true, "refId": "C" @@ -697,7 +697,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(otel_operator_sdk_events_received_total{job=\"webpage-operator\"}[5m])) by (event, action)", + "expr": "sum(rate(operator_sdk_events_received_total{service_name=\"josdk\"}[5m])) by (event, action)", "legendFormat": "{{event}} - {{action}}", "range": true, "refId": "A" @@ -787,7 +787,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(otel_operator_sdk_reconciliations_failed_total{job=\"webpage-operator\"}[5m])) by (exception)", + "expr": "sum(rate(operator_sdk_reconciliations_failed_total{service_name=\"josdk\"}[5m])) by (exception)", "legendFormat": "{{exception}}", "range": true, "refId": "A" @@ -877,7 +877,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(otel_operator_sdk_controllers_execution_reconcile_success_total{job=\"webpage-operator\"}[5m])) by (type)", + "expr": "sum(rate(operator_sdk_controllers_execution_reconcile_success_total{service_name=\"josdk\"}[5m])) by (type)", "legendFormat": "Success - {{type}}", "range": true, "refId": "A" @@ -888,7 +888,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(otel_operator_sdk_controllers_execution_reconcile_failure_total{job=\"webpage-operator\"}[5m])) by (exception)", + "expr": "sum(rate(operator_sdk_controllers_execution_reconcile_failure_total{service_name=\"josdk\"}[5m])) by (exception)", "legendFormat": "Failure - {{exception}}", "range": true, "refId": "B" @@ -978,7 +978,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(otel_operator_sdk_events_delete_total{job=\"webpage-operator\"}[5m])) by (kind, version)", + "expr": "sum(rate(operator_sdk_events_delete_total{service_name=\"josdk\"}[5m])) by (kind, version)", "legendFormat": "{{kind}} ({{version}})", "range": true, "refId": "A" @@ -1068,7 +1068,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(otel_operator_sdk_reconciliations_started_total{job=\"webpage-operator\", operator_sdk_reconciliations_retries_last=\"true\"}[5m]))", + "expr": "sum(rate(operator_sdk_reconciliations_started_total{service_name=\"josdk\", operator_sdk_reconciliations_retries_last=\"true\"}[5m]))", "legendFormat": "Last Retry Attempts", "range": true, "refId": "A" @@ -1079,7 +1079,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(otel_operator_sdk_reconciliations_started_total{job=\"webpage-operator\", operator_sdk_reconciliations_retries_last=\"false\"}[5m]))", + "expr": "sum(rate(operator_sdk_reconciliations_started_total{service_name=\"josdk\", operator_sdk_reconciliations_retries_last=\"false\"}[5m]))", "legendFormat": "Retries (Not Last)", "range": true, "refId": "B" diff --git a/observability/jvm-metrics-dashboard.json b/observability/jvm-metrics-dashboard.json index 0a817aa09c..528f29674e 100644 --- a/observability/jvm-metrics-dashboard.json +++ b/observability/jvm-metrics-dashboard.json @@ -106,7 +106,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "otel_jvm_memory_used_bytes{job=\"webpage-operator\"}", + "expr": "jvm_memory_used_bytes{service_name=\"josdk\"}", "legendFormat": "{{area}} - {{id}}", "range": true, "refId": "A" @@ -195,7 +195,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "otel_jvm_threads_live_threads{job=\"webpage-operator\"}", + "expr": "jvm_threads_live{service_name=\"josdk\"}", "legendFormat": "Live Threads", "range": true, "refId": "A" @@ -206,7 +206,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "otel_jvm_threads_daemon_threads{job=\"webpage-operator\"}", + "expr": "jvm_threads_daemon_threads{service_name=\"josdk\"}", "legendFormat": "Daemon Threads", "range": true, "refId": "B" @@ -217,7 +217,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "otel_jvm_threads_peak_threads{job=\"webpage-operator\"}", + "expr": "jvm_threads_peak_threads{service_name=\"josdk\"}", "legendFormat": "Peak Threads", "range": true, "refId": "C" @@ -306,7 +306,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "rate(otel_jvm_gc_pause_seconds_sum{job=\"webpage-operator\"}[5m])", + "expr": "rate(jvm_gc_pause_milliseconds_sum{service_name=\"josdk\"}[5m])", "legendFormat": "{{action}} - {{cause}}", "range": true, "refId": "A" @@ -395,7 +395,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "rate(otel_jvm_gc_pause_seconds_count{job=\"webpage-operator\"}[5m])", + "expr": "rate(jvm_gc_pause_milliseconds_count{service_name=\"josdk\"}[5m])", "legendFormat": "{{action}} - {{cause}}", "range": true, "refId": "A" @@ -453,7 +453,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "otel_system_cpu_usage{job=\"webpage-operator\"}", + "expr": "system_cpu_usage{service_name=\"josdk\"}", "legendFormat": "CPU Usage", "range": true, "refId": "A" @@ -511,7 +511,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "otel_jvm_classes_loaded_classes{job=\"webpage-operator\"}", + "expr": "jvm_classes_loaded{service_name=\"josdk\"}", "legendFormat": "Classes Loaded", "range": true, "refId": "A" @@ -540,7 +540,7 @@ } ] }, - "unit": "s" + "unit": "ms" }, "overrides": [] }, @@ -569,7 +569,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "otel_process_uptime_seconds{job=\"webpage-operator\"}", + "expr": "process_uptime_milliseconds{service_name=\"josdk\"}", "legendFormat": "Uptime", "range": true, "refId": "A" @@ -627,7 +627,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "otel_system_cpu_count{job=\"webpage-operator\"}", + "expr": "system_cpu_count{service_name=\"josdk\"}", "legendFormat": "CPU Count", "range": true, "refId": "A" @@ -716,7 +716,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "rate(otel_jvm_gc_memory_allocated_bytes_total{job=\"webpage-operator\"}[5m])", + "expr": "rate(jvm_gc_memory_allocated_bytes_total{service_name=\"josdk\"}[5m])", "legendFormat": "Allocated", "range": true, "refId": "A" @@ -727,7 +727,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "rate(otel_jvm_gc_memory_promoted_bytes_total{job=\"webpage-operator\"}[5m])", + "expr": "rate(jvm_gc_memory_promoted_bytes_total{service_name=\"josdk\"}[5m])", "legendFormat": "Promoted", "range": true, "refId": "B" @@ -816,7 +816,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "otel_jvm_memory_max_bytes{job=\"webpage-operator\", area=\"heap\"}", + "expr": "jvm_memory_max_bytes{service_name=\"josdk\", area=\"heap\"}", "legendFormat": "Max Heap", "range": true, "refId": "A" @@ -827,7 +827,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "otel_jvm_memory_committed_bytes{job=\"webpage-operator\", area=\"heap\"}", + "expr": "jvm_memory_committed_bytes{service_name=\"josdk\", area=\"heap\"}", "legendFormat": "Committed Heap", "range": true, "refId": "B" diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java index e43a253511..ad580736c1 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java @@ -58,7 +58,10 @@ public class WebPageOperator { public static void main(String[] args) throws IOException { log.info("WebServer Operator starting!"); - // TODO remove otel prefix, add job and additional labels?! + // TODO // todo change: + // operator_sdk_reconciliations_queue_size_webpagestandalonedependentsreconciler + // operator_sdk_reconciliations_executions_webpagestandalonedependentsreconciler + // => controller name as label // TODO add test for checking if there are metrics in prometheus // Load configuration from config.yaml Metrics metrics = initOTLPMetrics(); @@ -85,22 +88,24 @@ public static void main(String[] args) throws IOException { private static @NonNull Metrics initOTLPMetrics() { Map configProperties = loadConfigFromYaml(); - OtlpConfig otlpConfig = new OtlpConfig() { - @Override - public String prefix() { - return ""; - } - - @Override - public @Nullable String get(String key) { - return configProperties.get(key); - } - - @Override - public Map resourceAttributes() { - return Map.of("service.name","josdk","operator","webpage"); - } - }; + var otlpConfig = + new OtlpConfig() { + @Override + public String prefix() { + return ""; + } + + @Override + public @Nullable String get(String key) { + return configProperties.get(key); + } + + // these should come from env variables + @Override + public Map resourceAttributes() { + return Map.of("service.name", "josdk", "operator", "webpage"); + } + }; MeterRegistry registry = new OtlpMeterRegistry(otlpConfig, Clock.SYSTEM); From 6efffd6cb172dc6102a249c0046708be9d42b282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 9 Feb 2026 21:48:28 +0100 Subject: [PATCH 37/49] improve: micrometer metrics improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../micrometer/MicrometerMetrics.java | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java index 7beabb7a6e..cd0572db7b 100644 --- a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java +++ b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java @@ -22,6 +22,8 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import org.jspecify.annotations.NonNull; + import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.OperatorException; import io.javaoperatorsdk.operator.api.monitoring.Metrics; @@ -37,8 +39,6 @@ import io.micrometer.core.instrument.Tag; import io.micrometer.core.instrument.Timer; -import static io.javaoperatorsdk.operator.api.reconciler.Constants.CONTROLLER_NAME; - public class MicrometerMetrics implements Metrics { private static final String PREFIX = "operator.sdk."; @@ -48,8 +48,8 @@ public class MicrometerMetrics implements Metrics { private static final String RECONCILIATIONS_RETRIES_LAST = RECONCILIATIONS + "retries.last"; private static final String RECONCILIATIONS_RETRIES_NUMBER = RECONCILIATIONS + "retries.number"; private static final String RECONCILIATIONS_STARTED = RECONCILIATIONS + "started"; - private static final String RECONCILIATIONS_EXECUTIONS = PREFIX + RECONCILIATIONS + "executions."; - private static final String RECONCILIATIONS_QUEUE_SIZE = PREFIX + RECONCILIATIONS + "queue.size."; + private static final String RECONCILIATIONS_EXECUTIONS = PREFIX + RECONCILIATIONS + "executions"; + private static final String RECONCILIATIONS_QUEUE_SIZE = PREFIX + RECONCILIATIONS + "queue.size"; private static final String NAME = "name"; private static final String NAMESPACE = "namespace"; private static final String GROUP = "group"; @@ -59,6 +59,7 @@ public class MicrometerMetrics implements Metrics { private static final String METADATA_PREFIX = "resource."; private static final String CONTROLLERS_EXECUTION = "controllers.execution."; private static final String CONTROLLER = "controller"; + private static final String CONTROLLER_NAME = CONTROLLER + ".name"; private static final String SUCCESS_SUFFIX = ".success"; private static final String FAILURE_SUFFIX = ".failure"; private static final String TYPE = "type"; @@ -130,18 +131,27 @@ private MicrometerMetrics( public void controllerRegistered(Controller controller) { final var configuration = controller.getConfiguration(); final var name = configuration.getName(); - final var executingThreadsName = RECONCILIATIONS_EXECUTIONS + name; + final var executingThreadsRefName = reconciliationExecutionGaugeRefName(name); final var resourceClass = configuration.getResourceClass(); - final var tags = new ArrayList(3); + final var tags = new ArrayList(); + tags.add(Tag.of(CONTROLLER_NAME, name)); addGVKTags(GroupVersionKind.gvkFor(resourceClass), tags, false); AtomicInteger executingThreads = - registry.gauge(executingThreadsName, tags, new AtomicInteger(0)); - gauges.put(executingThreadsName, executingThreads); + registry.gauge(RECONCILIATIONS_EXECUTIONS, tags, new AtomicInteger(0)); + gauges.put(executingThreadsRefName, executingThreads); - final var controllerQueueName = RECONCILIATIONS_QUEUE_SIZE + name; + final var controllerQueueRefName = controllerQueueSizeGaugeRefName(name); AtomicInteger controllerQueueSize = - registry.gauge(controllerQueueName, tags, new AtomicInteger(0)); - gauges.put(controllerQueueName, controllerQueueSize); + registry.gauge(RECONCILIATIONS_QUEUE_SIZE, tags, new AtomicInteger(0)); + gauges.put(controllerQueueRefName, controllerQueueSize); + } + + private static @NonNull String reconciliationExecutionGaugeRefName(String controllerName) { + return RECONCILIATIONS_EXECUTIONS + "." + controllerName; + } + + private static @NonNull String controllerQueueSizeGaugeRefName(String controllerName) { + return RECONCILIATIONS_QUEUE_SIZE + "." + controllerName; } @Override @@ -223,7 +233,7 @@ public void reconcileCustomResource( String.valueOf(retryInfo.map(RetryInfo::isLastAttempt).orElse(true)))); var controllerQueueSize = - gauges.get(RECONCILIATIONS_QUEUE_SIZE + metadata.get(CONTROLLER_NAME)); + gauges.get(controllerQueueSizeGaugeRefName(metadata.get(CONTROLLER_NAME).toString())); controllerQueueSize.incrementAndGet(); } @@ -235,18 +245,18 @@ public void finishedReconciliation(HasMetadata resource, Map met @Override public void reconciliationExecutionStarted(HasMetadata resource, Map metadata) { var reconcilerExecutions = - gauges.get(RECONCILIATIONS_EXECUTIONS + metadata.get(CONTROLLER_NAME)); + gauges.get(reconciliationExecutionGaugeRefName(metadata.get(CONTROLLER_NAME).toString())); reconcilerExecutions.incrementAndGet(); } @Override public void reconciliationExecutionFinished(HasMetadata resource, Map metadata) { var reconcilerExecutions = - gauges.get(RECONCILIATIONS_EXECUTIONS + metadata.get(CONTROLLER_NAME)); + gauges.get(reconciliationExecutionGaugeRefName(metadata.get(CONTROLLER_NAME).toString())); reconcilerExecutions.decrementAndGet(); var controllerQueueSize = - gauges.get(RECONCILIATIONS_QUEUE_SIZE + metadata.get(CONTROLLER_NAME)); + gauges.get(controllerQueueSizeGaugeRefName(metadata.get(CONTROLLER_NAME).toString())); controllerQueueSize.decrementAndGet(); } From 4f38ca9d4e5b9be38ae41f185737cabd1b2837f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 10 Feb 2026 10:12:14 +0100 Subject: [PATCH 38/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../micrometer/MicrometerMetrics.java | 58 +++++++++++++++---- .../api/monitoring/AggregatedMetrics.java | 5 +- .../operator/api/monitoring/Metrics.java | 3 +- .../processing/event/EventProcessor.java | 2 +- .../api/monitoring/AggregatedMetricsTest.java | 10 ++-- 5 files changed, 57 insertions(+), 21 deletions(-) diff --git a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java index cd0572db7b..94391bec82 100644 --- a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java +++ b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java @@ -221,16 +221,18 @@ public void cleanupDoneFor(ResourceID resourceID, Map metadata) public void reconcileCustomResource( HasMetadata resource, RetryInfo retryInfoNullable, Map metadata) { Optional retryInfo = Optional.ofNullable(retryInfoNullable); - incrementCounter( - ResourceID.fromResource(resource), - RECONCILIATIONS_STARTED, - metadata, - Tag.of( - RECONCILIATIONS_RETRIES_NUMBER, - String.valueOf(retryInfo.map(RetryInfo::getAttemptCount).orElse(0))), - Tag.of( - RECONCILIATIONS_RETRIES_LAST, - String.valueOf(retryInfo.map(RetryInfo::isLastAttempt).orElse(true)))); + ResourceID resourceID = ResourceID.fromResource(resource); + + // Record the counter without retry tags + incrementCounter(resourceID, RECONCILIATIONS_STARTED, metadata); + + // Update retry number gauge + int retryNumber = retryInfo.map(RetryInfo::getAttemptCount).orElse(0); + updateGauge(resourceID, metadata, RECONCILIATIONS_RETRIES_NUMBER, retryNumber); + + // Update retry last attempt gauge (1 for true, 0 for false) + int isLastAttempt = retryInfo.map(RetryInfo::isLastAttempt).orElse(true) ? 1 : 0; + updateGauge(resourceID, metadata, RECONCILIATIONS_RETRIES_LAST, isLastAttempt); var controllerQueueSize = gauges.get(controllerQueueSizeGaugeRefName(metadata.get(CONTROLLER_NAME).toString())); @@ -238,8 +240,14 @@ public void reconcileCustomResource( } @Override - public void finishedReconciliation(HasMetadata resource, Map metadata) { - incrementCounter(ResourceID.fromResource(resource), RECONCILIATIONS_SUCCESS, metadata); + public void successfullyFinishedReconciliation( + HasMetadata resource, Map metadata) { + ResourceID resourceID = ResourceID.fromResource(resource); + incrementCounter(resourceID, RECONCILIATIONS_SUCCESS, metadata); + + // Reset retry gauges on successful reconciliation + updateGauge(resourceID, metadata, RECONCILIATIONS_RETRIES_NUMBER, 0); + updateGauge(resourceID, metadata, RECONCILIATIONS_RETRIES_LAST, 0); } @Override @@ -335,6 +343,32 @@ private void incrementCounter( counter.increment(); } + private void updateGauge( + ResourceID id, Map metadata, String gaugeName, int value) { + final var tags = new ArrayList(6); + addMetadataTags(id, metadata, tags, false); + + final var gaugeRefName = buildGaugeRefName(id, gaugeName); + AtomicInteger gauge = + gauges.computeIfAbsent( + gaugeRefName, + key -> { + AtomicInteger newGauge = + registry.gauge(PREFIX + gaugeName, tags, new AtomicInteger(0)); + // Find the meter in the registry and record it for cleanup + var meter = registry.find(PREFIX + gaugeName).tags(tags).gauge(); + if (meter != null) { + cleaner.recordAssociation(id, meter); + } + return newGauge; + }); + gauge.set(value); + } + + private String buildGaugeRefName(ResourceID id, String gaugeName) { + return gaugeName + "." + id.getName() + "." + id.getNamespace().orElse(CLUSTER); + } + protected Set recordedMeterIdsFor(ResourceID resourceID) { return cleaner.recordedMeterIdsFor(resourceID); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java index f66bdc47c6..4e3540bf55 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java @@ -103,8 +103,9 @@ public void cleanupDoneFor(ResourceID resourceID, Map metadata) } @Override - public void finishedReconciliation(HasMetadata resource, Map metadata) { - metricsList.forEach(metrics -> metrics.finishedReconciliation(resource, metadata)); + public void successfullyFinishedReconciliation( + HasMetadata resource, Map metadata) { + metricsList.forEach(metrics -> metrics.successfullyFinishedReconciliation(resource, metadata)); } @Override diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java index 10b2db6774..cda6fd167b 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java @@ -93,7 +93,8 @@ default void cleanupDoneFor(ResourceID resourceID, Map metadata) * @param resource the {@link ResourceID} associated with the resource being processed * @param metadata metadata associated with the resource being processed */ - default void finishedReconciliation(HasMetadata resource, Map metadata) {} + default void successfullyFinishedReconciliation( + HasMetadata resource, Map metadata) {} /** * Encapsulates the information about a controller execution i.e. a call to either {@link 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..4ff482f03e 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 @@ -292,7 +292,7 @@ synchronized void eventProcessingFinished( return; } cleanupOnSuccessfulExecution(executionScope); - metrics.finishedReconciliation(executionScope.getResource(), metricsMetadata); + metrics.successfullyFinishedReconciliation(executionScope.getResource(), metricsMetadata); if ((triggerOnAllEvents() && executionScope.isDeleteEvent()) || (!triggerOnAllEvents() && state.deleteEventPresent())) { cleanupForDeletedEvent(executionScope.getResourceID()); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetricsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetricsTest.java index 68142048b6..36a3ca0877 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetricsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetricsTest.java @@ -141,13 +141,13 @@ void cleanupDoneFor_shouldDelegateToAllMetricsInOrder() { } @Test - void finishedReconciliation_shouldDelegateToAllMetricsInOrder() { - aggregatedMetrics.finishedReconciliation(resource, metadata); + void successfullyFinishedReconciliation_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.successfullyFinishedReconciliation(resource, metadata); final var inOrder = inOrder(metrics1, metrics2, metrics3); - inOrder.verify(metrics1).finishedReconciliation(resource, metadata); - inOrder.verify(metrics2).finishedReconciliation(resource, metadata); - inOrder.verify(metrics3).finishedReconciliation(resource, metadata); + inOrder.verify(metrics1).successfullyFinishedReconciliation(resource, metadata); + inOrder.verify(metrics2).successfullyFinishedReconciliation(resource, metadata); + inOrder.verify(metrics3).successfullyFinishedReconciliation(resource, metadata); verifyNoMoreInteractions(metrics1, metrics2, metrics3); } From 87fd2995a71008b49f2ee16318d38c426946db17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 10 Feb 2026 14:17:37 +0100 Subject: [PATCH 39/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../micrometer/MicrometerMetrics.java | 69 ++++++++-- .../josdk-operator-metrics-dashboard.json | 123 ++++++++++++++++-- 2 files changed, 169 insertions(+), 23 deletions(-) diff --git a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java index 94391bec82..0886c46fc6 100644 --- a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java +++ b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java @@ -21,6 +21,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; import org.jspecify.annotations.NonNull; @@ -75,6 +76,7 @@ public class MicrometerMetrics implements Metrics { private final MeterRegistry registry; private final Map gauges = new ConcurrentHashMap<>(); private final Cleaner cleaner; + private final Consumer timerConfig; /** * Creates a MicrometerMetrics instance configured to not collect per-resource metrics, just @@ -84,7 +86,7 @@ public class MicrometerMetrics implements Metrics { * @return a MicrometerMetrics instance configured to not collect per-resource metrics */ public static MicrometerMetrics withoutPerResourceMetrics(MeterRegistry registry) { - return new MicrometerMetrics(registry, Cleaner.NOOP, false); + return new MicrometerMetrics(registry, Cleaner.NOOP, false, null); } /** @@ -108,7 +110,7 @@ public static MicrometerMetricsBuilder newMicrometerMetricsBuilder(MeterRegistry */ public static PerResourceCollectingMicrometerMetricsBuilder newPerResourceCollectingMicrometerMetricsBuilder(MeterRegistry registry) { - return new PerResourceCollectingMicrometerMetricsBuilder(registry); + return new PerResourceCollectingMicrometerMetricsBuilder(registry, null); } /** @@ -119,12 +121,21 @@ public static MicrometerMetricsBuilder newMicrometerMetricsBuilder(MeterRegistry * @param registry the {@link MeterRegistry} instance to use for metrics recording * @param cleaner the {@link Cleaner} to use * @param collectingPerResourceMetrics whether to collect per resource metrics + * @param timerConfig optional configuration for timers, defaults to publishing percentiles 0.5, + * 0.95, 0.99 and histogram */ private MicrometerMetrics( - MeterRegistry registry, Cleaner cleaner, boolean collectingPerResourceMetrics) { + MeterRegistry registry, + Cleaner cleaner, + boolean collectingPerResourceMetrics, + Consumer timerConfig) { this.registry = registry; this.cleaner = cleaner; this.collectPerResourceMetrics = collectingPerResourceMetrics; + this.timerConfig = + timerConfig != null + ? timerConfig + : builder -> builder.publishPercentiles(0.5, 0.95, 0.99).publishPercentileHistogram(); } @Override @@ -163,12 +174,9 @@ public T timeControllerExecution(ControllerExecution execution) { final var tags = new ArrayList(16); tags.add(Tag.of(CONTROLLER, name)); addMetadataTags(resourceID, metadata, tags, true); - final var timer = - Timer.builder(execName) - .tags(tags) - .publishPercentiles(0.3, 0.5, 0.95) - .publishPercentileHistogram() - .register(registry); + final var timerBuilder = Timer.builder(execName).tags(tags); + timerConfig.accept(timerBuilder); + final var timer = timerBuilder.register(registry); try { final var result = timer.record( @@ -379,8 +387,27 @@ public static class PerResourceCollectingMicrometerMetricsBuilder private int cleaningThreadsNumber; private int cleanUpDelayInSeconds; - private PerResourceCollectingMicrometerMetricsBuilder(MeterRegistry registry) { + private PerResourceCollectingMicrometerMetricsBuilder( + MeterRegistry registry, Consumer timerConfig) { super(registry); + this.executionTimerConfig = timerConfig; + } + + /** + * Configures the Timer used for timing controller executions. By default, timers are configured + * to publish percentiles 0.5, 0.95, 0.99 and a percentile histogram. You can set: {@code + * .minimumExpectedValue(Duration.ofMillis(...)).maximumExpectedValue(Duration.ofSeconds(...)) } + * so micrometer can create the buckets for you. + * + * @param executionTimerConfig a consumer that will configure the Timer.Builder. The builder + * will already have the metric name and tags set. + * @return this builder for method chaining + */ + @Override + public PerResourceCollectingMicrometerMetricsBuilder withExecutionTimerConfig( + Consumer executionTimerConfig) { + this.executionTimerConfig = executionTimerConfig; + return this; } /** @@ -412,23 +439,38 @@ public PerResourceCollectingMicrometerMetricsBuilder withCleanUpDelayInSeconds( public MicrometerMetrics build() { final var cleaner = new DelayedCleaner(registry, cleanUpDelayInSeconds, cleaningThreadsNumber); - return new MicrometerMetrics(registry, cleaner, true); + return new MicrometerMetrics(registry, cleaner, true, executionTimerConfig); } } public static class MicrometerMetricsBuilder { protected final MeterRegistry registry; private boolean collectingPerResourceMetrics = true; + protected Consumer executionTimerConfig = null; private MicrometerMetricsBuilder(MeterRegistry registry) { this.registry = registry; } + /** + * Configures the Timer used for timing controller executions. By default, timers are configured + * to publish percentiles 0.5, 0.95, 0.99 and a percentile histogram. + * + * @param executionTimerConfig a consumer that will configure the Timer.Builder. The builder + * will already have the metric name and tags set. + * @return this builder for method chaining + */ + public MicrometerMetricsBuilder withExecutionTimerConfig( + Consumer executionTimerConfig) { + this.executionTimerConfig = executionTimerConfig; + return this; + } + /** Configures the instance to collect metrics on a per-resource basis. */ @SuppressWarnings("unused") public PerResourceCollectingMicrometerMetricsBuilder collectingMetricsPerResource() { collectingPerResourceMetrics = true; - return new PerResourceCollectingMicrometerMetricsBuilder(registry); + return new PerResourceCollectingMicrometerMetricsBuilder(registry, executionTimerConfig); } /** @@ -442,7 +484,8 @@ public MicrometerMetricsBuilder notCollectingMetricsPerResource() { } public MicrometerMetrics build() { - return new MicrometerMetrics(registry, Cleaner.NOOP, collectingPerResourceMetrics); + return new MicrometerMetrics( + registry, Cleaner.NOOP, collectingPerResourceMetrics, executionTimerConfig); } } diff --git a/observability/josdk-operator-metrics-dashboard.json b/observability/josdk-operator-metrics-dashboard.json index 6b53d26611..6b6236cd2b 100644 --- a/observability/josdk-operator-metrics-dashboard.json +++ b/observability/josdk-operator-metrics-dashboard.json @@ -992,7 +992,7 @@ "type": "prometheus", "uid": "prometheus" }, - "description": "Reconciliation retry information", + "description": "Current retry attempt number for resources being retried", "fieldConfig": { "defaults": { "color": { @@ -1018,7 +1018,7 @@ "scaleDistribution": { "type": "linear" }, - "showPoints": "never", + "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", @@ -1035,10 +1035,18 @@ { "color": "green", "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 3 } ] }, - "unit": "ops" + "unit": "short" }, "overrides": [] }, @@ -1051,7 +1059,7 @@ "id": 12, "options": { "legend": { - "calcs": ["last", "mean"], + "calcs": ["last", "max"], "displayMode": "table", "placement": "bottom", "showLegend": true @@ -1068,24 +1076,119 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(operator_sdk_reconciliations_started_total{service_name=\"josdk\", operator_sdk_reconciliations_retries_last=\"true\"}[5m]))", - "legendFormat": "Last Retry Attempts", + "expr": "operator_sdk_reconciliations_retries_number{service_name=\"josdk\"}", + "legendFormat": "{{kind}}/{{name}} ({{namespace}})", "range": true, "refId": "A" + } + ], + "title": "Reconciliation Retry Attempts", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Resources currently on their last retry attempt (1 = last attempt, 0 = not last or no retry)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "stepAfter", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [ + { + "options": { + "0": { + "text": "No" + }, + "1": { + "text": "Yes" + } + }, + "type": "value" + } + ], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "short" }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 40 + }, + "id": 13, + "options": { + "legend": { + "calcs": ["last"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ { "datasource": { "type": "prometheus", "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(operator_sdk_reconciliations_started_total{service_name=\"josdk\", operator_sdk_reconciliations_retries_last=\"false\"}[5m]))", - "legendFormat": "Retries (Not Last)", + "expr": "operator_sdk_reconciliations_retries_last{service_name=\"josdk\"}", + "legendFormat": "{{kind}}/{{name}} ({{namespace}})", "range": true, - "refId": "B" + "refId": "A" } ], - "title": "Reconciliation Retry Rate", + "title": "Resources on Last Retry Attempt", "type": "timeseries" } ], From 98b260a9fab1883b1e9a5eabbea898b834a1d6ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 10 Feb 2026 18:03:32 +0100 Subject: [PATCH 40/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../micrometer/MicrometerMetrics.java | 202 +++--------------- .../AbstractMicrometerMetricsTestFixture.java | 17 -- .../micrometer/DefaultBehaviorIT.java | 23 -- .../DelayedMetricsCleaningOnDeleteIT.java | 46 ---- .../micrometer/NoPerResourceCollectionIT.java | 15 -- .../josdk-operator-metrics-dashboard.json | 126 +---------- .../operator/api/monitoring/Metrics.java | 11 - .../operator/processing/Controller.java | 22 -- sample-operators/webpage/k8s/webpage2.yaml | 34 +++ .../operator/sample/WebPageOperator.java | 56 +++-- .../webpage/src/main/resources/log4j2.xml | 2 +- 11 files changed, 119 insertions(+), 435 deletions(-) delete mode 100644 micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DelayedMetricsCleaningOnDeleteIT.java create mode 100644 sample-operators/webpage/k8s/webpage2.yaml diff --git a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java index 0886c46fc6..45f1517864 100644 --- a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java +++ b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java @@ -17,9 +17,6 @@ import java.util.*; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; @@ -35,22 +32,23 @@ import io.javaoperatorsdk.operator.processing.event.Event; import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent; -import io.micrometer.core.instrument.Meter; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Tag; import io.micrometer.core.instrument.Timer; public class MicrometerMetrics implements Metrics { + private static final String SUCCESS_SUFFIX = "success"; + private static final String FAILURE_SUFFIX = "failure"; private static final String PREFIX = "operator.sdk."; private static final String RECONCILIATIONS = "reconciliations."; - private static final String RECONCILIATIONS_FAILED = RECONCILIATIONS + "failed"; - private static final String RECONCILIATIONS_SUCCESS = RECONCILIATIONS + "success"; - private static final String RECONCILIATIONS_RETRIES_LAST = RECONCILIATIONS + "retries.last"; - private static final String RECONCILIATIONS_RETRIES_NUMBER = RECONCILIATIONS + "retries.number"; - private static final String RECONCILIATIONS_STARTED = RECONCILIATIONS + "started"; + private static final String RECONCILIATIONS_FAILED = PREFIX + RECONCILIATIONS + FAILURE_SUFFIX; + private static final String RECONCILIATIONS_SUCCESS = PREFIX + RECONCILIATIONS + SUCCESS_SUFFIX; + private static final String RECONCILIATIONS_RETRIES_NUMBER = + PREFIX + RECONCILIATIONS + "retries.number"; + private static final String RECONCILIATIONS_STARTED = PREFIX + RECONCILIATIONS + "started"; private static final String RECONCILIATIONS_EXECUTIONS = PREFIX + RECONCILIATIONS + "executions"; - private static final String RECONCILIATIONS_QUEUE_SIZE = PREFIX + RECONCILIATIONS + "queue.size"; + private static final String RECONCILIATIONS_QUEUE_SIZE = PREFIX + RECONCILIATIONS + "active"; private static final String NAME = "name"; private static final String NAMESPACE = "namespace"; private static final String GROUP = "group"; @@ -58,24 +56,25 @@ public class MicrometerMetrics implements Metrics { private static final String KIND = "kind"; private static final String SCOPE = "scope"; private static final String METADATA_PREFIX = "resource."; - private static final String CONTROLLERS_EXECUTION = "controllers.execution."; + private static final String CONTROLLERS = "controllers."; + private static final String RECONCILIATION_EXECUTION_TIME = + PREFIX + RECONCILIATIONS + "execution" + ".duration"; + private static final String CONTROLLERS_SUCCESSFUL_EXECUTION = + PREFIX + CONTROLLERS + SUCCESS_SUFFIX; + private static final String CONTROLLERS_FAILED_EXECUTION = PREFIX + CONTROLLERS + FAILURE_SUFFIX; private static final String CONTROLLER = "controller"; private static final String CONTROLLER_NAME = CONTROLLER + ".name"; - private static final String SUCCESS_SUFFIX = ".success"; - private static final String FAILURE_SUFFIX = ".failure"; - private static final String TYPE = "type"; - private static final String EXCEPTION = "exception"; private static final String EVENT = "event"; private static final String ACTION = "action"; - private static final String EVENTS_RECEIVED = "events.received"; - private static final String EVENTS_DELETE = "events.delete"; + private static final String EVENTS_RECEIVED = PREFIX + "events.received"; + private static final String EVENTS_DELETE = PREFIX + "events.delete"; private static final String CLUSTER = "cluster"; private static final String SIZE_SUFFIX = ".size"; private static final String UNKNOWN_ACTION = "UNKNOWN"; private final boolean collectPerResourceMetrics; private final MeterRegistry registry; + // todo double check if we actually need this private final Map gauges = new ConcurrentHashMap<>(); - private final Cleaner cleaner; private final Consumer timerConfig; /** @@ -86,7 +85,7 @@ public class MicrometerMetrics implements Metrics { * @return a MicrometerMetrics instance configured to not collect per-resource metrics */ public static MicrometerMetrics withoutPerResourceMetrics(MeterRegistry registry) { - return new MicrometerMetrics(registry, Cleaner.NOOP, false, null); + return new MicrometerMetrics(registry, false, null); } /** @@ -113,24 +112,21 @@ public static MicrometerMetricsBuilder newMicrometerMetricsBuilder(MeterRegistry return new PerResourceCollectingMicrometerMetricsBuilder(registry, null); } + // todo as v2 class + // todo make backwards compatible /** - * Creates a micrometer-based Metrics implementation that cleans up {@link Meter}s associated with - * deleted resources as specified by the (possibly {@code null}) provided {@link Cleaner} - * instance. + * Creates a micrometer-based Metrics implementation. * * @param registry the {@link MeterRegistry} instance to use for metrics recording - * @param cleaner the {@link Cleaner} to use * @param collectingPerResourceMetrics whether to collect per resource metrics * @param timerConfig optional configuration for timers, defaults to publishing percentiles 0.5, * 0.95, 0.99 and histogram */ private MicrometerMetrics( MeterRegistry registry, - Cleaner cleaner, boolean collectingPerResourceMetrics, Consumer timerConfig) { this.registry = registry; - this.cleaner = cleaner; this.collectPerResourceMetrics = collectingPerResourceMetrics; this.timerConfig = timerConfig != null @@ -165,16 +161,16 @@ public void controllerRegistered(Controller controller) { return RECONCILIATIONS_QUEUE_SIZE + "." + controllerName; } + // todo does it make sense to have both controller and reconciler execution counters? @Override public T timeControllerExecution(ControllerExecution execution) { final var name = execution.controllerName(); - final var execName = PREFIX + CONTROLLERS_EXECUTION + execution.name(); final var resourceID = execution.resourceID(); final var metadata = execution.metadata(); final var tags = new ArrayList(16); tags.add(Tag.of(CONTROLLER, name)); addMetadataTags(resourceID, metadata, tags, true); - final var timerBuilder = Timer.builder(execName).tags(tags); + final var timerBuilder = Timer.builder(RECONCILIATION_EXECUTION_TIME).tags(tags); timerConfig.accept(timerBuilder); final var timer = timerBuilder.register(registry); try { @@ -187,27 +183,23 @@ public T timeControllerExecution(ControllerExecution execution) { throw new OperatorException(e); } }); - final var successType = execution.successTypeName(result); - registry.counter(execName + SUCCESS_SUFFIX, CONTROLLER, name, TYPE, successType).increment(); + registry.counter(CONTROLLERS_SUCCESSFUL_EXECUTION, CONTROLLER, name).increment(); return result; } catch (Exception e) { - final var exception = e.getClass().getSimpleName(); - registry - .counter(execName + FAILURE_SUFFIX, CONTROLLER, name, EXCEPTION, exception) - .increment(); + registry.counter(CONTROLLERS_FAILED_EXECUTION, CONTROLLER, name).increment(); throw e; } } @Override public void receivedEvent(Event event, Map metadata) { - if (event instanceof ResourceEvent) { + if (event instanceof ResourceEvent resourceEvent) { incrementCounter( event.getRelatedCustomResourceID(), EVENTS_RECEIVED, metadata, Tag.of(EVENT, event.getClass().getSimpleName()), - Tag.of(ACTION, ((ResourceEvent) event).getAction().toString())); + Tag.of(ACTION, resourceEvent.getAction().toString())); } else { incrementCounter( event.getRelatedCustomResourceID(), @@ -221,8 +213,6 @@ public void receivedEvent(Event event, Map metadata) { @Override public void cleanupDoneFor(ResourceID resourceID, Map metadata) { incrementCounter(resourceID, EVENTS_DELETE, metadata); - - cleaner.removeMetersFor(resourceID); } @Override @@ -234,14 +224,11 @@ public void reconcileCustomResource( // Record the counter without retry tags incrementCounter(resourceID, RECONCILIATIONS_STARTED, metadata); + // todo add metric with for resources in exhaisted retry // Update retry number gauge int retryNumber = retryInfo.map(RetryInfo::getAttemptCount).orElse(0); updateGauge(resourceID, metadata, RECONCILIATIONS_RETRIES_NUMBER, retryNumber); - // Update retry last attempt gauge (1 for true, 0 for false) - int isLastAttempt = retryInfo.map(RetryInfo::isLastAttempt).orElse(true) ? 1 : 0; - updateGauge(resourceID, metadata, RECONCILIATIONS_RETRIES_LAST, isLastAttempt); - var controllerQueueSize = gauges.get(controllerQueueSizeGaugeRefName(metadata.get(CONTROLLER_NAME).toString())); controllerQueueSize.incrementAndGet(); @@ -255,7 +242,6 @@ public void successfullyFinishedReconciliation( // Reset retry gauges on successful reconciliation updateGauge(resourceID, metadata, RECONCILIATIONS_RETRIES_NUMBER, 0); - updateGauge(resourceID, metadata, RECONCILIATIONS_RETRIES_LAST, 0); } @Override @@ -279,17 +265,7 @@ public void reconciliationExecutionFinished(HasMetadata resource, Map metadata) { - var cause = exception.getCause(); - if (cause == null) { - cause = exception; - } else if (cause instanceof RuntimeException) { - cause = cause.getCause() != null ? cause.getCause() : cause; - } - incrementCounter( - ResourceID.fromResource(resource), - RECONCILIATIONS_FAILED, - metadata, - Tag.of(EXCEPTION, cause.getClass().getSimpleName())); + incrementCounter(ResourceID.fromResource(resource), RECONCILIATIONS_FAILED, metadata); } @Override @@ -346,8 +322,7 @@ private void incrementCounter( tags.addAll(List.of(additionalTags)); } - final var counter = registry.counter(PREFIX + counterName, tags); - cleaner.recordAssociation(id, counter); + final var counter = registry.counter(counterName, tags); counter.increment(); } @@ -356,37 +331,15 @@ private void updateGauge( final var tags = new ArrayList(6); addMetadataTags(id, metadata, tags, false); - final var gaugeRefName = buildGaugeRefName(id, gaugeName); AtomicInteger gauge = gauges.computeIfAbsent( - gaugeRefName, - key -> { - AtomicInteger newGauge = - registry.gauge(PREFIX + gaugeName, tags, new AtomicInteger(0)); - // Find the meter in the registry and record it for cleanup - var meter = registry.find(PREFIX + gaugeName).tags(tags).gauge(); - if (meter != null) { - cleaner.recordAssociation(id, meter); - } - return newGauge; - }); + gaugeName, key -> registry.gauge(gaugeName, tags, new AtomicInteger(0))); gauge.set(value); } - private String buildGaugeRefName(ResourceID id, String gaugeName) { - return gaugeName + "." + id.getName() + "." + id.getNamespace().orElse(CLUSTER); - } - - protected Set recordedMeterIdsFor(ResourceID resourceID) { - return cleaner.recordedMeterIdsFor(resourceID); - } - public static class PerResourceCollectingMicrometerMetricsBuilder extends MicrometerMetricsBuilder { - private int cleaningThreadsNumber; - private int cleanUpDelayInSeconds; - private PerResourceCollectingMicrometerMetricsBuilder( MeterRegistry registry, Consumer timerConfig) { super(registry); @@ -410,36 +363,9 @@ public PerResourceCollectingMicrometerMetricsBuilder withExecutionTimerConfig( return this; } - /** - * @param cleaningThreadsNumber the maximal number of threads that can be assigned to the - * removal of {@link Meter}s associated with deleted resources, defaults to 1 if not - * specified or if the provided number is lesser or equal to 0 - */ - public PerResourceCollectingMicrometerMetricsBuilder withCleaningThreadNumber( - int cleaningThreadsNumber) { - this.cleaningThreadsNumber = cleaningThreadsNumber <= 0 ? 1 : cleaningThreadsNumber; - return this; - } - - /** - * @param cleanUpDelayInSeconds the number of seconds to wait before {@link Meter}s are removed - * for deleted resources, defaults to 1 (meaning meters will be removed one second after the - * associated resource is deleted) if not specified or if the provided number is lesser than - * 0. Threading and the general interaction model of interacting with the API server means - * that it's not possible to ensure that meters are immediately deleted in all cases so a - * minimal delay of one second is always enforced - */ - public PerResourceCollectingMicrometerMetricsBuilder withCleanUpDelayInSeconds( - int cleanUpDelayInSeconds) { - this.cleanUpDelayInSeconds = Math.max(cleanUpDelayInSeconds, 1); - return this; - } - @Override public MicrometerMetrics build() { - final var cleaner = - new DelayedCleaner(registry, cleanUpDelayInSeconds, cleaningThreadsNumber); - return new MicrometerMetrics(registry, cleaner, true, executionTimerConfig); + return new MicrometerMetrics(registry, true, executionTimerConfig); } } @@ -484,69 +410,7 @@ public MicrometerMetricsBuilder notCollectingMetricsPerResource() { } public MicrometerMetrics build() { - return new MicrometerMetrics( - registry, Cleaner.NOOP, collectingPerResourceMetrics, executionTimerConfig); - } - } - - interface Cleaner { - Cleaner NOOP = new Cleaner() {}; - - default void removeMetersFor(ResourceID resourceID) {} - - default void recordAssociation(ResourceID resourceID, Meter meter) {} - - default Set recordedMeterIdsFor(ResourceID resourceID) { - return Collections.emptySet(); - } - } - - static class DefaultCleaner implements Cleaner { - private final Map> metersPerResource = new ConcurrentHashMap<>(); - private final MeterRegistry registry; - - private DefaultCleaner(MeterRegistry registry) { - this.registry = registry; - } - - @Override - public void removeMetersFor(ResourceID resourceID) { - // remove each meter - final var toClean = metersPerResource.get(resourceID); - if (toClean != null) { - toClean.forEach(registry::remove); - } - // then clean-up local recording of associations - metersPerResource.remove(resourceID); - } - - @Override - public void recordAssociation(ResourceID resourceID, Meter meter) { - metersPerResource.computeIfAbsent(resourceID, id -> new HashSet<>()).add(meter.getId()); - } - - @Override - public Set recordedMeterIdsFor(ResourceID resourceID) { - return metersPerResource.get(resourceID); - } - } - - static class DelayedCleaner extends MicrometerMetrics.DefaultCleaner { - private final ScheduledExecutorService metersCleaner; - private final int cleanUpDelayInSeconds; - - private DelayedCleaner( - MeterRegistry registry, int cleanUpDelayInSeconds, int cleaningThreadsNumber) { - super(registry); - this.cleanUpDelayInSeconds = cleanUpDelayInSeconds; - this.metersCleaner = Executors.newScheduledThreadPool(cleaningThreadsNumber); - } - - @Override - public void removeMetersFor(ResourceID resourceID) { - // schedule deletion of meters associated with ResourceID - metersCleaner.schedule( - () -> super.removeMetersFor(resourceID), cleanUpDelayInSeconds, TimeUnit.SECONDS); + return new MicrometerMetrics(registry, collectingPerResourceMetrics, executionTimerConfig); } } } diff --git a/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/AbstractMicrometerMetricsTestFixture.java b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/AbstractMicrometerMetricsTestFixture.java index 660ac5381c..b0346a2444 100644 --- a/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/AbstractMicrometerMetricsTestFixture.java +++ b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/AbstractMicrometerMetricsTestFixture.java @@ -30,7 +30,6 @@ import io.micrometer.core.instrument.Meter; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; -import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -66,28 +65,12 @@ void properlyHandlesResourceDeletion() throws Exception { .isEmpty()); final var resourceID = ResourceID.fromResource(created); - final var meters = preDeleteChecks(resourceID); // delete the resource and wait for it to be deleted operator.delete(testResource); await().until(() -> operator.get(ConfigMap.class, testResourceName) == null); - - postDeleteChecks(resourceID, meters); - } - - protected Set preDeleteChecks(ResourceID resourceID) { - // check that we properly recorded meters associated with the resource - final var meters = metrics.recordedMeterIdsFor(resourceID); - // metrics are collected per resource - assertThat(registry.getMetersAsString()).contains(resourceID.getName()); - assertThat(meters).isNotNull(); - assertThat(meters).isNotEmpty(); - return meters; } - protected void postDeleteChecks(ResourceID resourceID, Set recordedMeters) - throws Exception {} - @ControllerConfiguration private static class MetricsCleaningTestReconciler implements Reconciler, Cleaner { diff --git a/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DefaultBehaviorIT.java b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DefaultBehaviorIT.java index 928b01f55e..21376ea58d 100644 --- a/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DefaultBehaviorIT.java +++ b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DefaultBehaviorIT.java @@ -15,32 +15,9 @@ */ package io.javaoperatorsdk.operator.monitoring.micrometer; -import java.util.Collections; -import java.util.Set; - -import io.javaoperatorsdk.operator.processing.event.ResourceID; -import io.micrometer.core.instrument.Meter; - -import static org.assertj.core.api.Assertions.assertThat; - public class DefaultBehaviorIT extends AbstractMicrometerMetricsTestFixture { @Override protected MicrometerMetrics getMetrics() { return MicrometerMetrics.newMicrometerMetricsBuilder(registry).build(); } - - @Override - protected Set preDeleteChecks(ResourceID resourceID) { - // no meter should be recorded because we're not tracking anything to be deleted later - assertThat(metrics.recordedMeterIdsFor(resourceID)).isEmpty(); - // metrics are collected per resource by default for now, this will change in a future release - assertThat(registry.getMetersAsString()).contains(resourceID.getName()); - return Collections.emptySet(); - } - - @Override - protected void postDeleteChecks(ResourceID resourceID, Set recordedMeters) { - // meters should be neither recorded, nor removed by default - assertThat(registry.getRemoved()).isEmpty(); - } } diff --git a/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DelayedMetricsCleaningOnDeleteIT.java b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DelayedMetricsCleaningOnDeleteIT.java deleted file mode 100644 index bfed1f1089..0000000000 --- a/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DelayedMetricsCleaningOnDeleteIT.java +++ /dev/null @@ -1,46 +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.monitoring.micrometer; - -import java.time.Duration; -import java.util.Set; - -import io.javaoperatorsdk.operator.processing.event.ResourceID; -import io.micrometer.core.instrument.Meter; - -import static org.assertj.core.api.Assertions.assertThat; - -public class DelayedMetricsCleaningOnDeleteIT extends AbstractMicrometerMetricsTestFixture { - - private static final int testDelay = 1; - - @Override - protected MicrometerMetrics getMetrics() { - return MicrometerMetrics.newPerResourceCollectingMicrometerMetricsBuilder(registry) - .withCleanUpDelayInSeconds(testDelay) - .withCleaningThreadNumber(2) - .build(); - } - - @Override - protected void postDeleteChecks(ResourceID resourceID, Set recordedMeters) - throws Exception { - // check that the meters are properly removed after the specified delay - Thread.sleep(Duration.ofSeconds(testDelay).toMillis()); - assertThat(registry.getRemoved()).isEqualTo(recordedMeters); - assertThat(metrics.recordedMeterIdsFor(resourceID)).isNull(); - } -} diff --git a/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/NoPerResourceCollectionIT.java b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/NoPerResourceCollectionIT.java index c8dc32cd91..2fcd5c152f 100644 --- a/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/NoPerResourceCollectionIT.java +++ b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/NoPerResourceCollectionIT.java @@ -15,24 +15,9 @@ */ package io.javaoperatorsdk.operator.monitoring.micrometer; -import java.util.Collections; -import java.util.Set; - -import io.javaoperatorsdk.operator.processing.event.ResourceID; -import io.micrometer.core.instrument.Meter; - -import static org.assertj.core.api.Assertions.assertThat; - public class NoPerResourceCollectionIT extends AbstractMicrometerMetricsTestFixture { @Override protected MicrometerMetrics getMetrics() { return MicrometerMetrics.withoutPerResourceMetrics(registry); } - - @Override - protected Set preDeleteChecks(ResourceID resourceID) { - assertThat(metrics.recordedMeterIdsFor(resourceID)).isEmpty(); - assertThat(registry.getMetersAsString()).doesNotContain(resourceID.getName()); - return Collections.emptySet(); - } } diff --git a/observability/josdk-operator-metrics-dashboard.json b/observability/josdk-operator-metrics-dashboard.json index 6b6236cd2b..0ec869978e 100644 --- a/observability/josdk-operator-metrics-dashboard.json +++ b/observability/josdk-operator-metrics-dashboard.json @@ -369,13 +369,13 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(operator_sdk_reconciliations_queue_size{service_name=\"josdk\"})", - "legendFormat": "Queue Size", + "expr": "sum(operator_sdk_reconciliations_active{service_name=\"josdk\"})", + "legendFormat": "Active", "range": true, "refId": "A" } ], - "title": "Reconciliation Queue Size", + "title": "Active Reconciliations", "type": "gauge" }, { @@ -585,7 +585,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "histogram_quantile(0.50, sum(rate(operator_sdk_controllers_execution_reconcile_seconds_bucket{service_name=\"josdk\"}[5m])) by (le, controller))", + "expr": "histogram_quantile(0.50, sum(rate(operator_sdk_reconciliations_execution_duration_seconds_bucket{service_name=\"josdk\"}[5m])) by (le, controller))", "legendFormat": "p50 - {{controller}}", "range": true, "refId": "A" @@ -596,7 +596,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "histogram_quantile(0.95, sum(rate(operator_sdk_controllers_execution_reconcile_seconds_bucket{service_name=\"josdk\"}[5m])) by (le, controller))", + "expr": "histogram_quantile(0.95, sum(rate(operator_sdk_reconciliations_execution_duration_seconds_bucket{service_name=\"josdk\"}[5m])) by (le, controller))", "legendFormat": "p95 - {{controller}}", "range": true, "refId": "B" @@ -607,7 +607,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "histogram_quantile(0.99, sum(rate(operator_sdk_controllers_execution_reconcile_seconds_bucket{service_name=\"josdk\"}[5m])) by (le, controller))", + "expr": "histogram_quantile(0.99, sum(rate(operator_sdk_reconciliations_execution_duration_seconds_bucket{service_name=\"josdk\"}[5m])) by (le, controller))", "legendFormat": "p99 - {{controller}}", "range": true, "refId": "C" @@ -877,8 +877,8 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(operator_sdk_controllers_execution_reconcile_success_total{service_name=\"josdk\"}[5m])) by (type)", - "legendFormat": "Success - {{type}}", + "expr": "sum(rate(operator_sdk_controllers_success_total{service_name=\"josdk\"}[5m])) by (controller)", + "legendFormat": "Success - {{controller}}", "range": true, "refId": "A" }, @@ -888,8 +888,8 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(operator_sdk_controllers_execution_reconcile_failure_total{service_name=\"josdk\"}[5m])) by (exception)", - "legendFormat": "Failure - {{exception}}", + "expr": "sum(rate(operator_sdk_controllers_failure_total{service_name=\"josdk\"}[5m])) by (controller)", + "legendFormat": "Failure - {{controller}}", "range": true, "refId": "B" } @@ -1084,112 +1084,6 @@ ], "title": "Reconciliation Retry Attempts", "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "description": "Resources currently on their last retry attempt (1 = last attempt, 0 = not last or no retry)", - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { - "tooltip": false, - "viz": false, - "legend": false - }, - "lineInterpolation": "stepAfter", - "lineWidth": 1, - "pointSize": 5, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [ - { - "options": { - "0": { - "text": "No" - }, - "1": { - "text": "Yes" - } - }, - "type": "value" - } - ], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 1 - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 40 - }, - "id": 13, - "options": { - "legend": { - "calcs": ["last"], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "single", - "sort": "none" - } - }, - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "prometheus" - }, - "editorMode": "code", - "expr": "operator_sdk_reconciliations_retries_last{service_name=\"josdk\"}", - "legendFormat": "{{kind}}/{{name}} ({{namespace}})", - "range": true, - "refId": "A" - } - ], - "title": "Resources on Last Retry Attempt", - "type": "timeseries" } ], "refresh": "10s", diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java index cda6fd167b..7b3d5a9c03 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java @@ -125,17 +125,6 @@ interface ControllerExecution { */ String controllerName(); - /** - * Retrieves the name of the successful result when the reconciliation ended positively. - * Possible values comes from the different outcomes provided by {@link - * io.javaoperatorsdk.operator.api.reconciler.UpdateControl} or {@link - * io.javaoperatorsdk.operator.api.reconciler.DeleteControl}. - * - * @param result the reconciliation result - * @return a name associated with the specified outcome - */ - String successTypeName(T result); - /** * Retrieves the {@link ResourceID} of the resource associated with the controller execution * being considered diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java index bc3a43a9a3..3d6fc536a2 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java @@ -70,12 +70,7 @@ public class Controller

private static final Logger log = LoggerFactory.getLogger(Controller.class); private static final String CLEANUP = "cleanup"; - private static final String DELETE = "delete"; - private static final String FINALIZER_NOT_REMOVED = "finalizerNotRemoved"; private static final String RECONCILE = "reconcile"; - private static final String RESOURCE = "resource"; - private static final String STATUS = "status"; - private static final String BOTH = "both"; public static final String CLEANER_NOT_SUPPORTED_ON_ALL_EVENT_ERROR_MESSAGE = "Cleaner is not supported when triggerReconcilerOnAllEvents enabled."; public static final String @@ -155,18 +150,6 @@ public String controllerName() { return configuration.getName(); } - @Override - public String successTypeName(UpdateControl

result) { - String successType = RESOURCE; - if (result.isPatchStatus()) { - successType = STATUS; - } - if (result.isPatchResourceAndStatus()) { - successType = BOTH; - } - return successType; - } - @Override public ResourceID resourceID() { return ResourceID.fromResource(resource); @@ -208,11 +191,6 @@ public String controllerName() { return configuration.getName(); } - @Override - public String successTypeName(DeleteControl deleteControl) { - return deleteControl.isRemoveFinalizer() ? DELETE : FINALIZER_NOT_REMOVED; - } - @Override public ResourceID resourceID() { return ResourceID.fromResource(resource); diff --git a/sample-operators/webpage/k8s/webpage2.yaml b/sample-operators/webpage/k8s/webpage2.yaml new file mode 100644 index 0000000000..e9ae5ab19e --- /dev/null +++ b/sample-operators/webpage/k8s/webpage2.yaml @@ -0,0 +1,34 @@ +# +# Copyright Java Operator SDK Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: "sample.javaoperatorsdk/v1" +kind: WebPage +metadata: +# Use labels to match the resource with different reconciler implementations: +# labels: +# low-level: "true" + name: hellows2 +spec: + exposed: false + html: | + + + Hello Operator World + + + Hello World! + + diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java index ad580736c1..fb66f511d4 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java @@ -18,6 +18,7 @@ import java.io.IOException; import java.io.InputStream; import java.net.InetSocketAddress; +import java.time.Duration; import java.util.HashMap; import java.util.Map; @@ -34,12 +35,9 @@ import io.javaoperatorsdk.operator.sample.probes.StartupHandler; import io.micrometer.core.instrument.Clock; import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.binder.jvm.ClassLoaderMetrics; -import io.micrometer.core.instrument.binder.jvm.JvmGcMetrics; -import io.micrometer.core.instrument.binder.jvm.JvmMemoryMetrics; -import io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics; -import io.micrometer.core.instrument.binder.system.ProcessorMetrics; -import io.micrometer.core.instrument.binder.system.UptimeMetrics; +import io.micrometer.core.instrument.composite.CompositeMeterRegistry; +import io.micrometer.core.instrument.logging.LoggingMeterRegistry; +import io.micrometer.core.instrument.logging.LoggingRegistryConfig; import io.micrometer.registry.otlp.OtlpConfig; import io.micrometer.registry.otlp.OtlpMeterRegistry; @@ -87,6 +85,9 @@ public static void main(String[] args) throws IOException { } private static @NonNull Metrics initOTLPMetrics() { + CompositeMeterRegistry compositeRegistry = new CompositeMeterRegistry(); + + // Add OTLP registry Map configProperties = loadConfigFromYaml(); var otlpConfig = new OtlpConfig() { @@ -107,18 +108,43 @@ public Map resourceAttributes() { } }; - MeterRegistry registry = new OtlpMeterRegistry(otlpConfig, Clock.SYSTEM); + MeterRegistry otlpRegistry = new OtlpMeterRegistry(otlpConfig, Clock.SYSTEM); + compositeRegistry.add(otlpRegistry); + + // Add console logging registry if enabled (for development) + // String enableConsoleLogging = System.getenv("METRICS_CONSOLE_LOGGING"); + // todo remove + String enableConsoleLogging = "true"; + if ("true".equalsIgnoreCase(enableConsoleLogging)) { + log.info("Console metrics logging enabled"); + LoggingMeterRegistry loggingRegistry = + new LoggingMeterRegistry( + new LoggingRegistryConfig() { + @Override + public String get(String key) { + return null; + } + + @Override + public Duration step() { + return Duration.ofSeconds(10); // Log metrics every 30 seconds + } + }, + Clock.SYSTEM); + compositeRegistry.add(loggingRegistry); + } // Register JVM and system metrics log.info("Registering JVM and system metrics..."); - new JvmMemoryMetrics().bindTo(registry); - new JvmGcMetrics().bindTo(registry); - new JvmThreadMetrics().bindTo(registry); - new ClassLoaderMetrics().bindTo(registry); - new ProcessorMetrics().bindTo(registry); - new UptimeMetrics().bindTo(registry); - - return MicrometerMetrics.newPerResourceCollectingMicrometerMetricsBuilder(registry) + // todo add back + // new JvmMemoryMetrics().bindTo(compositeRegistry); + // new JvmGcMetrics().bindTo(compositeRegistry); + // new JvmThreadMetrics().bindTo(compositeRegistry); + // new ClassLoaderMetrics().bindTo(compositeRegistry); + // new ProcessorMetrics().bindTo(compositeRegistry); + // new UptimeMetrics().bindTo(compositeRegistry); + + return MicrometerMetrics.newPerResourceCollectingMicrometerMetricsBuilder(compositeRegistry) .collectingMetricsPerResource() .build(); } diff --git a/sample-operators/webpage/src/main/resources/log4j2.xml b/sample-operators/webpage/src/main/resources/log4j2.xml index ebe273e40e..7cced1edbd 100644 --- a/sample-operators/webpage/src/main/resources/log4j2.xml +++ b/sample-operators/webpage/src/main/resources/log4j2.xml @@ -19,7 +19,7 @@ - + From a0b98036b9a4cf909ca67e2143bf72a3c67c378c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 10 Feb 2026 19:45:40 +0100 Subject: [PATCH 41/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../micrometer/MicrometerMetrics.java | 297 +++++++------ .../micrometer/MicrometerMetricsV2.java | 413 ++++++++++++++++++ .../AbstractMicrometerMetricsTestFixture.java | 17 + .../micrometer/DefaultBehaviorIT.java | 23 + .../DelayedMetricsCleaningOnDeleteIT.java | 46 ++ .../micrometer/NoPerResourceCollectionIT.java | 15 + .../operator/api/monitoring/Metrics.java | 21 + .../operator/processing/Controller.java | 22 + .../operator/sample/MySQLSchemaOperator.java | 4 +- .../operator/sample/WebPageOperator.java | 4 +- 10 files changed, 735 insertions(+), 127 deletions(-) create mode 100644 micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java create mode 100644 micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DelayedMetricsCleaningOnDeleteIT.java diff --git a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java index 45f1517864..26971e7fa9 100644 --- a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java +++ b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java @@ -17,10 +17,10 @@ import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Consumer; - -import org.jspecify.annotations.NonNull; import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.OperatorException; @@ -32,23 +32,25 @@ import io.javaoperatorsdk.operator.processing.event.Event; import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent; +import io.micrometer.core.instrument.Meter; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Tag; import io.micrometer.core.instrument.Timer; +import static io.javaoperatorsdk.operator.api.reconciler.Constants.CONTROLLER_NAME; + +@Deprecated(forRemoval = true) public class MicrometerMetrics implements Metrics { - private static final String SUCCESS_SUFFIX = "success"; - private static final String FAILURE_SUFFIX = "failure"; private static final String PREFIX = "operator.sdk."; private static final String RECONCILIATIONS = "reconciliations."; - private static final String RECONCILIATIONS_FAILED = PREFIX + RECONCILIATIONS + FAILURE_SUFFIX; - private static final String RECONCILIATIONS_SUCCESS = PREFIX + RECONCILIATIONS + SUCCESS_SUFFIX; - private static final String RECONCILIATIONS_RETRIES_NUMBER = - PREFIX + RECONCILIATIONS + "retries.number"; - private static final String RECONCILIATIONS_STARTED = PREFIX + RECONCILIATIONS + "started"; - private static final String RECONCILIATIONS_EXECUTIONS = PREFIX + RECONCILIATIONS + "executions"; - private static final String RECONCILIATIONS_QUEUE_SIZE = PREFIX + RECONCILIATIONS + "active"; + private static final String RECONCILIATIONS_FAILED = RECONCILIATIONS + "failed"; + private static final String RECONCILIATIONS_SUCCESS = RECONCILIATIONS + "success"; + private static final String RECONCILIATIONS_RETRIES_LAST = RECONCILIATIONS + "retries.last"; + private static final String RECONCILIATIONS_RETRIES_NUMBER = RECONCILIATIONS + "retries.number"; + private static final String RECONCILIATIONS_STARTED = RECONCILIATIONS + "started"; + private static final String RECONCILIATIONS_EXECUTIONS = PREFIX + RECONCILIATIONS + "executions."; + private static final String RECONCILIATIONS_QUEUE_SIZE = PREFIX + RECONCILIATIONS + "queue.size."; private static final String NAME = "name"; private static final String NAMESPACE = "namespace"; private static final String GROUP = "group"; @@ -56,26 +58,23 @@ public class MicrometerMetrics implements Metrics { private static final String KIND = "kind"; private static final String SCOPE = "scope"; private static final String METADATA_PREFIX = "resource."; - private static final String CONTROLLERS = "controllers."; - private static final String RECONCILIATION_EXECUTION_TIME = - PREFIX + RECONCILIATIONS + "execution" + ".duration"; - private static final String CONTROLLERS_SUCCESSFUL_EXECUTION = - PREFIX + CONTROLLERS + SUCCESS_SUFFIX; - private static final String CONTROLLERS_FAILED_EXECUTION = PREFIX + CONTROLLERS + FAILURE_SUFFIX; + private static final String CONTROLLERS_EXECUTION = "controllers.execution."; private static final String CONTROLLER = "controller"; - private static final String CONTROLLER_NAME = CONTROLLER + ".name"; + private static final String SUCCESS_SUFFIX = ".success"; + private static final String FAILURE_SUFFIX = ".failure"; + private static final String TYPE = "type"; + private static final String EXCEPTION = "exception"; private static final String EVENT = "event"; private static final String ACTION = "action"; - private static final String EVENTS_RECEIVED = PREFIX + "events.received"; - private static final String EVENTS_DELETE = PREFIX + "events.delete"; + private static final String EVENTS_RECEIVED = "events.received"; + private static final String EVENTS_DELETE = "events.delete"; private static final String CLUSTER = "cluster"; private static final String SIZE_SUFFIX = ".size"; private static final String UNKNOWN_ACTION = "UNKNOWN"; private final boolean collectPerResourceMetrics; private final MeterRegistry registry; - // todo double check if we actually need this private final Map gauges = new ConcurrentHashMap<>(); - private final Consumer timerConfig; + private final Cleaner cleaner; /** * Creates a MicrometerMetrics instance configured to not collect per-resource metrics, just @@ -85,7 +84,7 @@ public class MicrometerMetrics implements Metrics { * @return a MicrometerMetrics instance configured to not collect per-resource metrics */ public static MicrometerMetrics withoutPerResourceMetrics(MeterRegistry registry) { - return new MicrometerMetrics(registry, false, null); + return new MicrometerMetrics(registry, Cleaner.NOOP, false); } /** @@ -109,70 +108,58 @@ public static MicrometerMetricsBuilder newMicrometerMetricsBuilder(MeterRegistry */ public static PerResourceCollectingMicrometerMetricsBuilder newPerResourceCollectingMicrometerMetricsBuilder(MeterRegistry registry) { - return new PerResourceCollectingMicrometerMetricsBuilder(registry, null); + return new PerResourceCollectingMicrometerMetricsBuilder(registry); } - // todo as v2 class - // todo make backwards compatible /** - * Creates a micrometer-based Metrics implementation. + * Creates a micrometer-based Metrics implementation that cleans up {@link Meter}s associated with + * deleted resources as specified by the (possibly {@code null}) provided {@link Cleaner} + * instance. * * @param registry the {@link MeterRegistry} instance to use for metrics recording + * @param cleaner the {@link Cleaner} to use * @param collectingPerResourceMetrics whether to collect per resource metrics - * @param timerConfig optional configuration for timers, defaults to publishing percentiles 0.5, - * 0.95, 0.99 and histogram */ private MicrometerMetrics( - MeterRegistry registry, - boolean collectingPerResourceMetrics, - Consumer timerConfig) { + MeterRegistry registry, Cleaner cleaner, boolean collectingPerResourceMetrics) { this.registry = registry; + this.cleaner = cleaner; this.collectPerResourceMetrics = collectingPerResourceMetrics; - this.timerConfig = - timerConfig != null - ? timerConfig - : builder -> builder.publishPercentiles(0.5, 0.95, 0.99).publishPercentileHistogram(); } @Override public void controllerRegistered(Controller controller) { final var configuration = controller.getConfiguration(); final var name = configuration.getName(); - final var executingThreadsRefName = reconciliationExecutionGaugeRefName(name); + final var executingThreadsName = RECONCILIATIONS_EXECUTIONS + name; final var resourceClass = configuration.getResourceClass(); - final var tags = new ArrayList(); - tags.add(Tag.of(CONTROLLER_NAME, name)); + final var tags = new ArrayList(3); addGVKTags(GroupVersionKind.gvkFor(resourceClass), tags, false); AtomicInteger executingThreads = - registry.gauge(RECONCILIATIONS_EXECUTIONS, tags, new AtomicInteger(0)); - gauges.put(executingThreadsRefName, executingThreads); + registry.gauge(executingThreadsName, tags, new AtomicInteger(0)); + gauges.put(executingThreadsName, executingThreads); - final var controllerQueueRefName = controllerQueueSizeGaugeRefName(name); + final var controllerQueueName = RECONCILIATIONS_QUEUE_SIZE + name; AtomicInteger controllerQueueSize = - registry.gauge(RECONCILIATIONS_QUEUE_SIZE, tags, new AtomicInteger(0)); - gauges.put(controllerQueueRefName, controllerQueueSize); - } - - private static @NonNull String reconciliationExecutionGaugeRefName(String controllerName) { - return RECONCILIATIONS_EXECUTIONS + "." + controllerName; + registry.gauge(controllerQueueName, tags, new AtomicInteger(0)); + gauges.put(controllerQueueName, controllerQueueSize); } - private static @NonNull String controllerQueueSizeGaugeRefName(String controllerName) { - return RECONCILIATIONS_QUEUE_SIZE + "." + controllerName; - } - - // todo does it make sense to have both controller and reconciler execution counters? @Override public T timeControllerExecution(ControllerExecution execution) { final var name = execution.controllerName(); + final var execName = PREFIX + CONTROLLERS_EXECUTION + execution.name(); final var resourceID = execution.resourceID(); final var metadata = execution.metadata(); final var tags = new ArrayList(16); tags.add(Tag.of(CONTROLLER, name)); addMetadataTags(resourceID, metadata, tags, true); - final var timerBuilder = Timer.builder(RECONCILIATION_EXECUTION_TIME).tags(tags); - timerConfig.accept(timerBuilder); - final var timer = timerBuilder.register(registry); + final var timer = + Timer.builder(execName) + .tags(tags) + .publishPercentiles(0.3, 0.5, 0.95) + .publishPercentileHistogram() + .register(registry); try { final var result = timer.record( @@ -183,23 +170,27 @@ public T timeControllerExecution(ControllerExecution execution) { throw new OperatorException(e); } }); - registry.counter(CONTROLLERS_SUCCESSFUL_EXECUTION, CONTROLLER, name).increment(); + final var successType = execution.successTypeName(result); + registry.counter(execName + SUCCESS_SUFFIX, CONTROLLER, name, TYPE, successType).increment(); return result; } catch (Exception e) { - registry.counter(CONTROLLERS_FAILED_EXECUTION, CONTROLLER, name).increment(); + final var exception = e.getClass().getSimpleName(); + registry + .counter(execName + FAILURE_SUFFIX, CONTROLLER, name, EXCEPTION, exception) + .increment(); throw e; } } @Override public void receivedEvent(Event event, Map metadata) { - if (event instanceof ResourceEvent resourceEvent) { + if (event instanceof ResourceEvent) { incrementCounter( event.getRelatedCustomResourceID(), EVENTS_RECEIVED, metadata, Tag.of(EVENT, event.getClass().getSimpleName()), - Tag.of(ACTION, resourceEvent.getAction().toString())); + Tag.of(ACTION, ((ResourceEvent) event).getAction().toString())); } else { incrementCounter( event.getRelatedCustomResourceID(), @@ -213,59 +204,68 @@ public void receivedEvent(Event event, Map metadata) { @Override public void cleanupDoneFor(ResourceID resourceID, Map metadata) { incrementCounter(resourceID, EVENTS_DELETE, metadata); + + cleaner.removeMetersFor(resourceID); } @Override public void reconcileCustomResource( HasMetadata resource, RetryInfo retryInfoNullable, Map metadata) { Optional retryInfo = Optional.ofNullable(retryInfoNullable); - ResourceID resourceID = ResourceID.fromResource(resource); - - // Record the counter without retry tags - incrementCounter(resourceID, RECONCILIATIONS_STARTED, metadata); - - // todo add metric with for resources in exhaisted retry - // Update retry number gauge - int retryNumber = retryInfo.map(RetryInfo::getAttemptCount).orElse(0); - updateGauge(resourceID, metadata, RECONCILIATIONS_RETRIES_NUMBER, retryNumber); + incrementCounter( + ResourceID.fromResource(resource), + RECONCILIATIONS_STARTED, + metadata, + Tag.of( + RECONCILIATIONS_RETRIES_NUMBER, + String.valueOf(retryInfo.map(RetryInfo::getAttemptCount).orElse(0))), + Tag.of( + RECONCILIATIONS_RETRIES_LAST, + String.valueOf(retryInfo.map(RetryInfo::isLastAttempt).orElse(true)))); var controllerQueueSize = - gauges.get(controllerQueueSizeGaugeRefName(metadata.get(CONTROLLER_NAME).toString())); + gauges.get(RECONCILIATIONS_QUEUE_SIZE + metadata.get(CONTROLLER_NAME)); controllerQueueSize.incrementAndGet(); } @Override public void successfullyFinishedReconciliation( HasMetadata resource, Map metadata) { - ResourceID resourceID = ResourceID.fromResource(resource); - incrementCounter(resourceID, RECONCILIATIONS_SUCCESS, metadata); - - // Reset retry gauges on successful reconciliation - updateGauge(resourceID, metadata, RECONCILIATIONS_RETRIES_NUMBER, 0); + incrementCounter(ResourceID.fromResource(resource), RECONCILIATIONS_SUCCESS, metadata); } @Override public void reconciliationExecutionStarted(HasMetadata resource, Map metadata) { var reconcilerExecutions = - gauges.get(reconciliationExecutionGaugeRefName(metadata.get(CONTROLLER_NAME).toString())); + gauges.get(RECONCILIATIONS_EXECUTIONS + metadata.get(CONTROLLER_NAME)); reconcilerExecutions.incrementAndGet(); } @Override public void reconciliationExecutionFinished(HasMetadata resource, Map metadata) { var reconcilerExecutions = - gauges.get(reconciliationExecutionGaugeRefName(metadata.get(CONTROLLER_NAME).toString())); + gauges.get(RECONCILIATIONS_EXECUTIONS + metadata.get(CONTROLLER_NAME)); reconcilerExecutions.decrementAndGet(); var controllerQueueSize = - gauges.get(controllerQueueSizeGaugeRefName(metadata.get(CONTROLLER_NAME).toString())); + gauges.get(RECONCILIATIONS_QUEUE_SIZE + metadata.get(CONTROLLER_NAME)); controllerQueueSize.decrementAndGet(); } @Override public void failedReconciliation( HasMetadata resource, Exception exception, Map metadata) { - incrementCounter(ResourceID.fromResource(resource), RECONCILIATIONS_FAILED, metadata); + var cause = exception.getCause(); + if (cause == null) { + cause = exception; + } else if (cause instanceof RuntimeException) { + cause = cause.getCause() != null ? cause.getCause() : cause; + } + incrementCounter( + ResourceID.fromResource(resource), + RECONCILIATIONS_FAILED, + metadata, + Tag.of(EXCEPTION, cause.getClass().getSimpleName())); } @Override @@ -322,81 +322,71 @@ private void incrementCounter( tags.addAll(List.of(additionalTags)); } - final var counter = registry.counter(counterName, tags); + final var counter = registry.counter(PREFIX + counterName, tags); + cleaner.recordAssociation(id, counter); counter.increment(); } - private void updateGauge( - ResourceID id, Map metadata, String gaugeName, int value) { - final var tags = new ArrayList(6); - addMetadataTags(id, metadata, tags, false); - - AtomicInteger gauge = - gauges.computeIfAbsent( - gaugeName, key -> registry.gauge(gaugeName, tags, new AtomicInteger(0))); - gauge.set(value); + protected Set recordedMeterIdsFor(ResourceID resourceID) { + return cleaner.recordedMeterIdsFor(resourceID); } public static class PerResourceCollectingMicrometerMetricsBuilder extends MicrometerMetricsBuilder { - private PerResourceCollectingMicrometerMetricsBuilder( - MeterRegistry registry, Consumer timerConfig) { + private int cleaningThreadsNumber; + private int cleanUpDelayInSeconds; + + private PerResourceCollectingMicrometerMetricsBuilder(MeterRegistry registry) { super(registry); - this.executionTimerConfig = timerConfig; } /** - * Configures the Timer used for timing controller executions. By default, timers are configured - * to publish percentiles 0.5, 0.95, 0.99 and a percentile histogram. You can set: {@code - * .minimumExpectedValue(Duration.ofMillis(...)).maximumExpectedValue(Duration.ofSeconds(...)) } - * so micrometer can create the buckets for you. - * - * @param executionTimerConfig a consumer that will configure the Timer.Builder. The builder - * will already have the metric name and tags set. - * @return this builder for method chaining + * @param cleaningThreadsNumber the maximal number of threads that can be assigned to the + * removal of {@link Meter}s associated with deleted resources, defaults to 1 if not + * specified or if the provided number is lesser or equal to 0 */ - @Override - public PerResourceCollectingMicrometerMetricsBuilder withExecutionTimerConfig( - Consumer executionTimerConfig) { - this.executionTimerConfig = executionTimerConfig; + public PerResourceCollectingMicrometerMetricsBuilder withCleaningThreadNumber( + int cleaningThreadsNumber) { + this.cleaningThreadsNumber = cleaningThreadsNumber <= 0 ? 1 : cleaningThreadsNumber; + return this; + } + + /** + * @param cleanUpDelayInSeconds the number of seconds to wait before {@link Meter}s are removed + * for deleted resources, defaults to 1 (meaning meters will be removed one second after the + * associated resource is deleted) if not specified or if the provided number is lesser than + * 0. Threading and the general interaction model of interacting with the API server means + * that it's not possible to ensure that meters are immediately deleted in all cases so a + * minimal delay of one second is always enforced + */ + public PerResourceCollectingMicrometerMetricsBuilder withCleanUpDelayInSeconds( + int cleanUpDelayInSeconds) { + this.cleanUpDelayInSeconds = Math.max(cleanUpDelayInSeconds, 1); return this; } @Override public MicrometerMetrics build() { - return new MicrometerMetrics(registry, true, executionTimerConfig); + final var cleaner = + new DelayedCleaner(registry, cleanUpDelayInSeconds, cleaningThreadsNumber); + return new MicrometerMetrics(registry, cleaner, true); } } public static class MicrometerMetricsBuilder { protected final MeterRegistry registry; private boolean collectingPerResourceMetrics = true; - protected Consumer executionTimerConfig = null; private MicrometerMetricsBuilder(MeterRegistry registry) { this.registry = registry; } - /** - * Configures the Timer used for timing controller executions. By default, timers are configured - * to publish percentiles 0.5, 0.95, 0.99 and a percentile histogram. - * - * @param executionTimerConfig a consumer that will configure the Timer.Builder. The builder - * will already have the metric name and tags set. - * @return this builder for method chaining - */ - public MicrometerMetricsBuilder withExecutionTimerConfig( - Consumer executionTimerConfig) { - this.executionTimerConfig = executionTimerConfig; - return this; - } - /** Configures the instance to collect metrics on a per-resource basis. */ @SuppressWarnings("unused") public PerResourceCollectingMicrometerMetricsBuilder collectingMetricsPerResource() { collectingPerResourceMetrics = true; - return new PerResourceCollectingMicrometerMetricsBuilder(registry, executionTimerConfig); + return new PerResourceCollectingMicrometerMetricsBuilder(registry); } /** @@ -410,7 +400,68 @@ public MicrometerMetricsBuilder notCollectingMetricsPerResource() { } public MicrometerMetrics build() { - return new MicrometerMetrics(registry, collectingPerResourceMetrics, executionTimerConfig); + return new MicrometerMetrics(registry, Cleaner.NOOP, collectingPerResourceMetrics); + } + } + + interface Cleaner { + Cleaner NOOP = new Cleaner() {}; + + default void removeMetersFor(ResourceID resourceID) {} + + default void recordAssociation(ResourceID resourceID, Meter meter) {} + + default Set recordedMeterIdsFor(ResourceID resourceID) { + return Collections.emptySet(); + } + } + + static class DefaultCleaner implements Cleaner { + private final Map> metersPerResource = new ConcurrentHashMap<>(); + private final MeterRegistry registry; + + private DefaultCleaner(MeterRegistry registry) { + this.registry = registry; + } + + @Override + public void removeMetersFor(ResourceID resourceID) { + // remove each meter + final var toClean = metersPerResource.get(resourceID); + if (toClean != null) { + toClean.forEach(registry::remove); + } + // then clean-up local recording of associations + metersPerResource.remove(resourceID); + } + + @Override + public void recordAssociation(ResourceID resourceID, Meter meter) { + metersPerResource.computeIfAbsent(resourceID, id -> new HashSet<>()).add(meter.getId()); + } + + @Override + public Set recordedMeterIdsFor(ResourceID resourceID) { + return metersPerResource.get(resourceID); + } + } + + static class DelayedCleaner extends MicrometerMetrics.DefaultCleaner { + private final ScheduledExecutorService metersCleaner; + private final int cleanUpDelayInSeconds; + + private DelayedCleaner( + MeterRegistry registry, int cleanUpDelayInSeconds, int cleaningThreadsNumber) { + super(registry); + this.cleanUpDelayInSeconds = cleanUpDelayInSeconds; + this.metersCleaner = Executors.newScheduledThreadPool(cleaningThreadsNumber); + } + + @Override + public void removeMetersFor(ResourceID resourceID) { + // schedule deletion of meters associated with ResourceID + metersCleaner.schedule( + () -> super.removeMetersFor(resourceID), cleanUpDelayInSeconds, TimeUnit.SECONDS); } } } diff --git a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java new file mode 100644 index 0000000000..eeb20c67db --- /dev/null +++ b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java @@ -0,0 +1,413 @@ +/* + * 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.monitoring.micrometer; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +import org.jspecify.annotations.NonNull; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.api.monitoring.Metrics; +import io.javaoperatorsdk.operator.api.reconciler.Constants; +import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; +import io.javaoperatorsdk.operator.processing.Controller; +import io.javaoperatorsdk.operator.processing.GroupVersionKind; +import io.javaoperatorsdk.operator.processing.event.Event; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Timer; + +public class MicrometerMetricsV2 implements Metrics { + + private static final String SUCCESS_SUFFIX = "success"; + private static final String FAILURE_SUFFIX = "failure"; + private static final String PREFIX = "operator.sdk."; + private static final String RECONCILIATIONS = "reconciliations."; + private static final String RECONCILIATIONS_FAILED = PREFIX + RECONCILIATIONS + FAILURE_SUFFIX; + private static final String RECONCILIATIONS_SUCCESS = PREFIX + RECONCILIATIONS + SUCCESS_SUFFIX; + private static final String RECONCILIATIONS_RETRIES_NUMBER = + PREFIX + RECONCILIATIONS + "retries.number"; + private static final String RECONCILIATIONS_STARTED = PREFIX + RECONCILIATIONS + "started"; + private static final String RECONCILIATIONS_EXECUTIONS = PREFIX + RECONCILIATIONS + "executions"; + private static final String RECONCILIATIONS_QUEUE_SIZE = PREFIX + RECONCILIATIONS + "active"; + private static final String NAME = "name"; + private static final String NAMESPACE = "namespace"; + private static final String GROUP = "group"; + private static final String VERSION = "version"; + private static final String KIND = "kind"; + private static final String SCOPE = "scope"; + private static final String METADATA_PREFIX = "resource."; + private static final String CONTROLLERS = "controllers."; + private static final String RECONCILIATION_EXECUTION_TIME = + PREFIX + RECONCILIATIONS + "execution" + ".duration"; + private static final String CONTROLLERS_SUCCESSFUL_EXECUTION = + PREFIX + CONTROLLERS + SUCCESS_SUFFIX; + private static final String CONTROLLERS_FAILED_EXECUTION = PREFIX + CONTROLLERS + FAILURE_SUFFIX; + private static final String CONTROLLER = "controller"; + private static final String CONTROLLER_NAME = CONTROLLER + ".name"; + private static final String EVENT = "event"; + private static final String ACTION = "action"; + private static final String EVENTS_RECEIVED = PREFIX + "events.received"; + private static final String EVENTS_DELETE = PREFIX + "events.delete"; + private static final String CLUSTER = "cluster"; + private static final String SIZE_SUFFIX = ".size"; + private static final String UNKNOWN_ACTION = "UNKNOWN"; + private final boolean collectPerResourceMetrics; + private final MeterRegistry registry; + private final Map gauges = new ConcurrentHashMap<>(); + private final Consumer timerConfig; + + /** + * Creates a MicrometerMetricsV2 instance configured to not collect per-resource metrics, just + * aggregates per resource **type** + * + * @param registry the {@link MeterRegistry} instance to use for metrics recording + * @return a MicrometerMetricsV2 instance configured to not collect per-resource metrics + */ + public static MicrometerMetricsV2 withoutPerResourceMetrics(MeterRegistry registry) { + return new MicrometerMetricsV2(registry, false, null); + } + + /** + * Creates a new builder to configure how the eventual MicrometerMetricsV2 instance will behave. + * + * @param registry the {@link MeterRegistry} instance to use for metrics recording + * @return a MicrometerMetricsV2 instance configured to not collect per-resource metrics + * @see MicrometerMetricsBuilder + */ + public static MicrometerMetricsBuilder newMicrometerMetricsBuilder(MeterRegistry registry) { + return new MicrometerMetricsBuilder(registry); + } + + /** + * Creates a new builder to configure how the eventual MicrometerMetricsV2 instance will behave, + * pre-configuring it to collect metrics per resource. + * + * @param registry the {@link MeterRegistry} instance to use for metrics recording + * @return a MicrometerMetricsV2 instance configured to not collect per-resource metrics + * @see PerResourceCollectingMicrometerMetricsBuilder + */ + public static PerResourceCollectingMicrometerMetricsBuilder + newPerResourceCollectingMicrometerMetricsBuilder(MeterRegistry registry) { + return new PerResourceCollectingMicrometerMetricsBuilder(registry, null); + } + + /** + * Creates a micrometer-based Metrics implementation. + * + * @param registry the {@link MeterRegistry} instance to use for metrics recording + * @param collectingPerResourceMetrics whether to collect per resource metrics + * @param timerConfig optional configuration for timers, defaults to publishing percentiles 0.5, + * 0.95, 0.99 and histogram + */ + private MicrometerMetricsV2( + MeterRegistry registry, + boolean collectingPerResourceMetrics, + Consumer timerConfig) { + this.registry = registry; + this.collectPerResourceMetrics = collectingPerResourceMetrics; + this.timerConfig = + timerConfig != null + ? timerConfig + : builder -> builder.publishPercentiles(0.5, 0.95, 0.99).publishPercentileHistogram(); + } + + @Override + public void controllerRegistered(Controller controller) { + final var configuration = controller.getConfiguration(); + final var name = configuration.getName(); + final var executingThreadsRefName = reconciliationExecutionGaugeRefName(name); + final var resourceClass = configuration.getResourceClass(); + final var tags = new ArrayList(); + tags.add(Tag.of(CONTROLLER_NAME, name)); + addGVKTags(GroupVersionKind.gvkFor(resourceClass), tags, false); + AtomicInteger executingThreads = + registry.gauge(RECONCILIATIONS_EXECUTIONS, tags, new AtomicInteger(0)); + gauges.put(executingThreadsRefName, executingThreads); + + final var controllerQueueRefName = controllerQueueSizeGaugeRefName(name); + AtomicInteger controllerQueueSize = + registry.gauge(RECONCILIATIONS_QUEUE_SIZE, tags, new AtomicInteger(0)); + gauges.put(controllerQueueRefName, controllerQueueSize); + } + + private static @NonNull String reconciliationExecutionGaugeRefName(String controllerName) { + return RECONCILIATIONS_EXECUTIONS + "." + controllerName; + } + + private static @NonNull String controllerQueueSizeGaugeRefName(String controllerName) { + return RECONCILIATIONS_QUEUE_SIZE + "." + controllerName; + } + + // todo does it make sense to have both controller and reconciler execution counters? + @Override + public T timeControllerExecution(ControllerExecution execution) { + final var name = execution.controllerName(); + final var resourceID = execution.resourceID(); + final var metadata = execution.metadata(); + final var tags = new ArrayList(16); + tags.add(Tag.of(CONTROLLER, name)); + addMetadataTags(resourceID, metadata, tags, true); + final var timerBuilder = Timer.builder(RECONCILIATION_EXECUTION_TIME).tags(tags); + timerConfig.accept(timerBuilder); + final var timer = timerBuilder.register(registry); + try { + final var result = + timer.record( + () -> { + try { + return execution.execute(); + } catch (Exception e) { + throw new OperatorException(e); + } + }); + registry.counter(CONTROLLERS_SUCCESSFUL_EXECUTION, CONTROLLER, name).increment(); + return result; + } catch (Exception e) { + registry.counter(CONTROLLERS_FAILED_EXECUTION, CONTROLLER, name).increment(); + throw e; + } + } + + @Override + public void receivedEvent(Event event, Map metadata) { + if (event instanceof ResourceEvent resourceEvent) { + incrementCounter( + event.getRelatedCustomResourceID(), + EVENTS_RECEIVED, + metadata, + Tag.of(EVENT, event.getClass().getSimpleName()), + Tag.of(ACTION, resourceEvent.getAction().toString())); + } else { + incrementCounter( + event.getRelatedCustomResourceID(), + EVENTS_RECEIVED, + metadata, + Tag.of(EVENT, event.getClass().getSimpleName()), + Tag.of(ACTION, UNKNOWN_ACTION)); + } + } + + @Override + public void cleanupDoneFor(ResourceID resourceID, Map metadata) { + incrementCounter(resourceID, EVENTS_DELETE, metadata); + } + + @Override + public void reconcileCustomResource( + HasMetadata resource, RetryInfo retryInfoNullable, Map metadata) { + Optional retryInfo = Optional.ofNullable(retryInfoNullable); + ResourceID resourceID = ResourceID.fromResource(resource); + + // Record the counter without retry tags + incrementCounter(resourceID, RECONCILIATIONS_STARTED, metadata); + + // todo add metric with for resources in exhaisted retry + // Update retry number gauge + int retryNumber = retryInfo.map(RetryInfo::getAttemptCount).orElse(0); + updateGauge(resourceID, metadata, RECONCILIATIONS_RETRIES_NUMBER, retryNumber); + + var controllerQueueSize = + gauges.get(controllerQueueSizeGaugeRefName(metadata.get(CONTROLLER_NAME).toString())); + controllerQueueSize.incrementAndGet(); + } + + @Override + public void successfullyFinishedReconciliation( + HasMetadata resource, Map metadata) { + ResourceID resourceID = ResourceID.fromResource(resource); + incrementCounter(resourceID, RECONCILIATIONS_SUCCESS, metadata); + + // Reset retry gauges on successful reconciliation + updateGauge(resourceID, metadata, RECONCILIATIONS_RETRIES_NUMBER, 0); + } + + @Override + public void reconciliationExecutionStarted(HasMetadata resource, Map metadata) { + var reconcilerExecutions = + gauges.get(reconciliationExecutionGaugeRefName(metadata.get(CONTROLLER_NAME).toString())); + reconcilerExecutions.incrementAndGet(); + } + + @Override + public void reconciliationExecutionFinished(HasMetadata resource, Map metadata) { + var reconcilerExecutions = + gauges.get(reconciliationExecutionGaugeRefName(metadata.get(CONTROLLER_NAME).toString())); + reconcilerExecutions.decrementAndGet(); + + var controllerQueueSize = + gauges.get(controllerQueueSizeGaugeRefName(metadata.get(CONTROLLER_NAME).toString())); + controllerQueueSize.decrementAndGet(); + } + + @Override + public void failedReconciliation( + HasMetadata resource, Exception exception, Map metadata) { + incrementCounter(ResourceID.fromResource(resource), RECONCILIATIONS_FAILED, metadata); + } + + @Override + public > T monitorSizeOf(T map, String name) { + return registry.gaugeMapSize(PREFIX + name + SIZE_SUFFIX, Collections.emptyList(), map); + } + + private void addMetadataTags( + ResourceID resourceID, Map metadata, List tags, boolean prefixed) { + if (collectPerResourceMetrics) { + addTag(NAME, resourceID.getName(), tags, prefixed); + addTagOmittingOnEmptyValue(NAMESPACE, resourceID.getNamespace().orElse(null), tags, prefixed); + } + addTag(SCOPE, getScope(resourceID), tags, prefixed); + final var gvk = (GroupVersionKind) metadata.get(Constants.RESOURCE_GVK_KEY); + if (gvk != null) { + addGVKTags(gvk, tags, prefixed); + } + } + + private static void addTag(String name, String value, List tags, boolean prefixed) { + tags.add(Tag.of(getPrefixedMetadataTag(name, prefixed), value)); + } + + private static void addTagOmittingOnEmptyValue( + String name, String value, List tags, boolean prefixed) { + if (value != null && !value.isBlank()) { + addTag(name, value, tags, prefixed); + } + } + + private static String getPrefixedMetadataTag(String tagName, boolean prefixed) { + return prefixed ? METADATA_PREFIX + tagName : tagName; + } + + private static String getScope(ResourceID resourceID) { + return resourceID.getNamespace().isPresent() ? NAMESPACE : CLUSTER; + } + + private static void addGVKTags(GroupVersionKind gvk, List tags, boolean prefixed) { + addTagOmittingOnEmptyValue(GROUP, gvk.getGroup(), tags, prefixed); + addTag(VERSION, gvk.getVersion(), tags, prefixed); + addTag(KIND, gvk.getKind(), tags, prefixed); + } + + private void incrementCounter( + ResourceID id, String counterName, Map metadata, Tag... additionalTags) { + final var additionalTagsNb = + additionalTags != null && additionalTags.length > 0 ? additionalTags.length : 0; + final var metadataNb = metadata != null ? metadata.size() : 0; + final var tags = new ArrayList(6 + additionalTagsNb + metadataNb); + addMetadataTags(id, metadata, tags, false); + if (additionalTagsNb > 0) { + tags.addAll(List.of(additionalTags)); + } + + final var counter = registry.counter(counterName, tags); + counter.increment(); + } + + private void updateGauge( + ResourceID id, Map metadata, String gaugeName, int value) { + final var tags = new ArrayList(6); + addMetadataTags(id, metadata, tags, false); + + AtomicInteger gauge = + gauges.computeIfAbsent( + gaugeName, key -> registry.gauge(gaugeName, tags, new AtomicInteger(0))); + gauge.set(value); + } + + public static class PerResourceCollectingMicrometerMetricsBuilder + extends MicrometerMetricsBuilder { + + private PerResourceCollectingMicrometerMetricsBuilder( + MeterRegistry registry, Consumer timerConfig) { + super(registry); + this.executionTimerConfig = timerConfig; + } + + /** + * Configures the Timer used for timing controller executions. By default, timers are configured + * to publish percentiles 0.5, 0.95, 0.99 and a percentile histogram. You can set: {@code + * .minimumExpectedValue(Duration.ofMillis(...)).maximumExpectedValue(Duration.ofSeconds(...)) } + * so micrometer can create the buckets for you. + * + * @param executionTimerConfig a consumer that will configure the Timer.Builder. The builder + * will already have the metric name and tags set. + * @return this builder for method chaining + */ + @Override + public PerResourceCollectingMicrometerMetricsBuilder withExecutionTimerConfig( + Consumer executionTimerConfig) { + this.executionTimerConfig = executionTimerConfig; + return this; + } + + @Override + public MicrometerMetricsV2 build() { + return new MicrometerMetricsV2(registry, true, executionTimerConfig); + } + } + + public static class MicrometerMetricsBuilder { + protected final MeterRegistry registry; + private boolean collectingPerResourceMetrics = true; + protected Consumer executionTimerConfig = null; + + private MicrometerMetricsBuilder(MeterRegistry registry) { + this.registry = registry; + } + + /** + * Configures the Timer used for timing controller executions. By default, timers are configured + * to publish percentiles 0.5, 0.95, 0.99 and a percentile histogram. + * + * @param executionTimerConfig a consumer that will configure the Timer.Builder. The builder + * will already have the metric name and tags set. + * @return this builder for method chaining + */ + public MicrometerMetricsBuilder withExecutionTimerConfig( + Consumer executionTimerConfig) { + this.executionTimerConfig = executionTimerConfig; + return this; + } + + /** Configures the instance to collect metrics on a per-resource basis. */ + @SuppressWarnings("unused") + public PerResourceCollectingMicrometerMetricsBuilder collectingMetricsPerResource() { + collectingPerResourceMetrics = true; + return new PerResourceCollectingMicrometerMetricsBuilder(registry, executionTimerConfig); + } + + /** + * Configures the instance to only collect metrics per resource **type**, in an aggregate + * fashion, instead of per resource instance. + */ + @SuppressWarnings("unused") + public MicrometerMetricsBuilder notCollectingMetricsPerResource() { + collectingPerResourceMetrics = false; + return this; + } + + public MicrometerMetricsV2 build() { + return new MicrometerMetricsV2(registry, collectingPerResourceMetrics, executionTimerConfig); + } + } +} diff --git a/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/AbstractMicrometerMetricsTestFixture.java b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/AbstractMicrometerMetricsTestFixture.java index b0346a2444..660ac5381c 100644 --- a/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/AbstractMicrometerMetricsTestFixture.java +++ b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/AbstractMicrometerMetricsTestFixture.java @@ -30,6 +30,7 @@ import io.micrometer.core.instrument.Meter; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -65,12 +66,28 @@ void properlyHandlesResourceDeletion() throws Exception { .isEmpty()); final var resourceID = ResourceID.fromResource(created); + final var meters = preDeleteChecks(resourceID); // delete the resource and wait for it to be deleted operator.delete(testResource); await().until(() -> operator.get(ConfigMap.class, testResourceName) == null); + + postDeleteChecks(resourceID, meters); + } + + protected Set preDeleteChecks(ResourceID resourceID) { + // check that we properly recorded meters associated with the resource + final var meters = metrics.recordedMeterIdsFor(resourceID); + // metrics are collected per resource + assertThat(registry.getMetersAsString()).contains(resourceID.getName()); + assertThat(meters).isNotNull(); + assertThat(meters).isNotEmpty(); + return meters; } + protected void postDeleteChecks(ResourceID resourceID, Set recordedMeters) + throws Exception {} + @ControllerConfiguration private static class MetricsCleaningTestReconciler implements Reconciler, Cleaner { diff --git a/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DefaultBehaviorIT.java b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DefaultBehaviorIT.java index 21376ea58d..928b01f55e 100644 --- a/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DefaultBehaviorIT.java +++ b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DefaultBehaviorIT.java @@ -15,9 +15,32 @@ */ package io.javaoperatorsdk.operator.monitoring.micrometer; +import java.util.Collections; +import java.util.Set; + +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.micrometer.core.instrument.Meter; + +import static org.assertj.core.api.Assertions.assertThat; + public class DefaultBehaviorIT extends AbstractMicrometerMetricsTestFixture { @Override protected MicrometerMetrics getMetrics() { return MicrometerMetrics.newMicrometerMetricsBuilder(registry).build(); } + + @Override + protected Set preDeleteChecks(ResourceID resourceID) { + // no meter should be recorded because we're not tracking anything to be deleted later + assertThat(metrics.recordedMeterIdsFor(resourceID)).isEmpty(); + // metrics are collected per resource by default for now, this will change in a future release + assertThat(registry.getMetersAsString()).contains(resourceID.getName()); + return Collections.emptySet(); + } + + @Override + protected void postDeleteChecks(ResourceID resourceID, Set recordedMeters) { + // meters should be neither recorded, nor removed by default + assertThat(registry.getRemoved()).isEmpty(); + } } diff --git a/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DelayedMetricsCleaningOnDeleteIT.java b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DelayedMetricsCleaningOnDeleteIT.java new file mode 100644 index 0000000000..bfed1f1089 --- /dev/null +++ b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/DelayedMetricsCleaningOnDeleteIT.java @@ -0,0 +1,46 @@ +/* + * 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.monitoring.micrometer; + +import java.time.Duration; +import java.util.Set; + +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.micrometer.core.instrument.Meter; + +import static org.assertj.core.api.Assertions.assertThat; + +public class DelayedMetricsCleaningOnDeleteIT extends AbstractMicrometerMetricsTestFixture { + + private static final int testDelay = 1; + + @Override + protected MicrometerMetrics getMetrics() { + return MicrometerMetrics.newPerResourceCollectingMicrometerMetricsBuilder(registry) + .withCleanUpDelayInSeconds(testDelay) + .withCleaningThreadNumber(2) + .build(); + } + + @Override + protected void postDeleteChecks(ResourceID resourceID, Set recordedMeters) + throws Exception { + // check that the meters are properly removed after the specified delay + Thread.sleep(Duration.ofSeconds(testDelay).toMillis()); + assertThat(registry.getRemoved()).isEqualTo(recordedMeters); + assertThat(metrics.recordedMeterIdsFor(resourceID)).isNull(); + } +} diff --git a/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/NoPerResourceCollectionIT.java b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/NoPerResourceCollectionIT.java index 2fcd5c152f..c8dc32cd91 100644 --- a/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/NoPerResourceCollectionIT.java +++ b/micrometer-support/src/test/java/io/javaoperatorsdk/operator/monitoring/micrometer/NoPerResourceCollectionIT.java @@ -15,9 +15,24 @@ */ package io.javaoperatorsdk.operator.monitoring.micrometer; +import java.util.Collections; +import java.util.Set; + +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.micrometer.core.instrument.Meter; + +import static org.assertj.core.api.Assertions.assertThat; + public class NoPerResourceCollectionIT extends AbstractMicrometerMetricsTestFixture { @Override protected MicrometerMetrics getMetrics() { return MicrometerMetrics.withoutPerResourceMetrics(registry); } + + @Override + protected Set preDeleteChecks(ResourceID resourceID) { + assertThat(metrics.recordedMeterIdsFor(resourceID)).isEmpty(); + assertThat(registry.getMetersAsString()).doesNotContain(resourceID.getName()); + return Collections.emptySet(); + } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java index 7b3d5a9c03..976254f62b 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java @@ -84,6 +84,16 @@ default void reconciliationExecutionFinished( */ default void cleanupDoneFor(ResourceID resourceID, Map metadata) {} + /** + * @deprecated use {@link Metrics#successfullyFinishedReconciliation(HasMetadata, Map)} + * @param resource the {@link ResourceID} associated with the resource being processed + * @param metadata metadata associated with the resource being processed + */ + @Deprecated(forRemoval = true) + default void finishedReconciliation(HasMetadata resource, Map metadata) { + successfullyFinishedReconciliation(resource, metadata); + } + /** * Called when the {@link * io.javaoperatorsdk.operator.api.reconciler.Reconciler#reconcile(HasMetadata, Context)} method @@ -125,6 +135,17 @@ interface ControllerExecution { */ String controllerName(); + /** + * Retrieves the name of the successful result when the reconciliation ended positively. + * Possible values comes from the different outcomes provided by {@link + * io.javaoperatorsdk.operator.api.reconciler.UpdateControl} or {@link + * io.javaoperatorsdk.operator.api.reconciler.DeleteControl}. + * + * @param result the reconciliation result + * @return a name associated with the specified outcome + */ + String successTypeName(T result); + /** * Retrieves the {@link ResourceID} of the resource associated with the controller execution * being considered diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java index 3d6fc536a2..bc3a43a9a3 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java @@ -70,7 +70,12 @@ public class Controller

private static final Logger log = LoggerFactory.getLogger(Controller.class); private static final String CLEANUP = "cleanup"; + private static final String DELETE = "delete"; + private static final String FINALIZER_NOT_REMOVED = "finalizerNotRemoved"; private static final String RECONCILE = "reconcile"; + private static final String RESOURCE = "resource"; + private static final String STATUS = "status"; + private static final String BOTH = "both"; public static final String CLEANER_NOT_SUPPORTED_ON_ALL_EVENT_ERROR_MESSAGE = "Cleaner is not supported when triggerReconcilerOnAllEvents enabled."; public static final String @@ -150,6 +155,18 @@ public String controllerName() { return configuration.getName(); } + @Override + public String successTypeName(UpdateControl

result) { + String successType = RESOURCE; + if (result.isPatchStatus()) { + successType = STATUS; + } + if (result.isPatchResourceAndStatus()) { + successType = BOTH; + } + return successType; + } + @Override public ResourceID resourceID() { return ResourceID.fromResource(resource); @@ -191,6 +208,11 @@ public String controllerName() { return configuration.getName(); } + @Override + public String successTypeName(DeleteControl deleteControl) { + return deleteControl.isRemoveFinalizer() ? DELETE : FINALIZER_NOT_REMOVED; + } + @Override public ResourceID resourceID() { return ResourceID.fromResource(resource); diff --git a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperator.java b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperator.java index 20dafac5be..c734e60345 100644 --- a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperator.java +++ b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperator.java @@ -26,7 +26,7 @@ import org.takes.http.FtBasic; import io.javaoperatorsdk.operator.Operator; -import io.javaoperatorsdk.operator.monitoring.micrometer.MicrometerMetrics; +import io.javaoperatorsdk.operator.monitoring.micrometer.MicrometerMetricsV2; import io.javaoperatorsdk.operator.sample.dependent.ResourcePollerConfig; import io.javaoperatorsdk.operator.sample.dependent.SchemaDependentResource; import io.micrometer.core.instrument.logging.LoggingMeterRegistry; @@ -42,7 +42,7 @@ public static void main(String[] args) throws IOException { new Operator( overrider -> overrider.withMetrics( - MicrometerMetrics.withoutPerResourceMetrics(new LoggingMeterRegistry()))); + MicrometerMetricsV2.withoutPerResourceMetrics(new LoggingMeterRegistry()))); MySQLSchemaReconciler schemaReconciler = new MySQLSchemaReconciler(); diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java index fb66f511d4..e2f6a4fca6 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java @@ -30,7 +30,7 @@ import io.javaoperatorsdk.operator.Operator; import io.javaoperatorsdk.operator.api.monitoring.Metrics; -import io.javaoperatorsdk.operator.monitoring.micrometer.MicrometerMetrics; +import io.javaoperatorsdk.operator.monitoring.micrometer.MicrometerMetricsV2; import io.javaoperatorsdk.operator.sample.probes.LivenessHandler; import io.javaoperatorsdk.operator.sample.probes.StartupHandler; import io.micrometer.core.instrument.Clock; @@ -144,7 +144,7 @@ public Duration step() { // new ProcessorMetrics().bindTo(compositeRegistry); // new UptimeMetrics().bindTo(compositeRegistry); - return MicrometerMetrics.newPerResourceCollectingMicrometerMetricsBuilder(compositeRegistry) + return MicrometerMetricsV2.newPerResourceCollectingMicrometerMetricsBuilder(compositeRegistry) .collectingMetricsPerResource() .build(); } From 5a89db65150046763c126963b288c8ead6c86136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 10 Feb 2026 21:08:01 +0100 Subject: [PATCH 42/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../micrometer/MicrometerMetricsV2.java | 308 ++++++------------ .../api/monitoring/AggregatedMetrics.java | 1 + .../operator/api/monitoring/Metrics.java | 50 ++- .../processing/event/EventProcessor.java | 7 +- .../operator/sample/MySQLSchemaOperator.java | 3 +- .../operator/sample/WebPageOperator.java | 5 - 6 files changed, 136 insertions(+), 238 deletions(-) diff --git a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java index eeb20c67db..9b75845776 100644 --- a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java +++ b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java @@ -28,7 +28,6 @@ import io.javaoperatorsdk.operator.api.reconciler.Constants; import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; import io.javaoperatorsdk.operator.processing.Controller; -import io.javaoperatorsdk.operator.processing.GroupVersionKind; import io.javaoperatorsdk.operator.processing.event.Event; import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent; @@ -38,93 +37,72 @@ public class MicrometerMetricsV2 implements Metrics { - private static final String SUCCESS_SUFFIX = "success"; - private static final String FAILURE_SUFFIX = "failure"; private static final String PREFIX = "operator.sdk."; - private static final String RECONCILIATIONS = "reconciliations."; - private static final String RECONCILIATIONS_FAILED = PREFIX + RECONCILIATIONS + FAILURE_SUFFIX; - private static final String RECONCILIATIONS_SUCCESS = PREFIX + RECONCILIATIONS + SUCCESS_SUFFIX; - private static final String RECONCILIATIONS_RETRIES_NUMBER = - PREFIX + RECONCILIATIONS + "retries.number"; - private static final String RECONCILIATIONS_STARTED = PREFIX + RECONCILIATIONS + "started"; - private static final String RECONCILIATIONS_EXECUTIONS = PREFIX + RECONCILIATIONS + "executions"; - private static final String RECONCILIATIONS_QUEUE_SIZE = PREFIX + RECONCILIATIONS + "active"; - private static final String NAME = "name"; - private static final String NAMESPACE = "namespace"; - private static final String GROUP = "group"; - private static final String VERSION = "version"; - private static final String KIND = "kind"; - private static final String SCOPE = "scope"; - private static final String METADATA_PREFIX = "resource."; - private static final String CONTROLLERS = "controllers."; - private static final String RECONCILIATION_EXECUTION_TIME = - PREFIX + RECONCILIATIONS + "execution" + ".duration"; - private static final String CONTROLLERS_SUCCESSFUL_EXECUTION = - PREFIX + CONTROLLERS + SUCCESS_SUFFIX; - private static final String CONTROLLERS_FAILED_EXECUTION = PREFIX + CONTROLLERS + FAILURE_SUFFIX; - private static final String CONTROLLER = "controller"; - private static final String CONTROLLER_NAME = CONTROLLER + ".name"; + private static final String CONTROLLER_NAME = "controller.name"; private static final String EVENT = "event"; private static final String ACTION = "action"; private static final String EVENTS_RECEIVED = PREFIX + "events.received"; private static final String EVENTS_DELETE = PREFIX + "events.delete"; - private static final String CLUSTER = "cluster"; - private static final String SIZE_SUFFIX = ".size"; private static final String UNKNOWN_ACTION = "UNKNOWN"; - private final boolean collectPerResourceMetrics; + public static final String TOTAL_SUFFIX = ".total"; + private static final String SUCCESS_SUFFIX = "success"; + private static final String FAILURE_SUFFIX = "failure"; + + private static final String RECONCILIATIONS = "reconciliations."; + + private static final String RECONCILIATIONS_FAILED = + PREFIX + RECONCILIATIONS + FAILURE_SUFFIX + TOTAL_SUFFIX; + private static final String RECONCILIATIONS_SUCCESS = + PREFIX + RECONCILIATIONS + SUCCESS_SUFFIX + TOTAL_SUFFIX; + private static final String RECONCILIATIONS_RETRIES_NUMBER = + PREFIX + RECONCILIATIONS + "retries" + TOTAL_SUFFIX; + private static final String RECONCILIATIONS_RETRIES_LAST_ATTEMPT = + PREFIX + RECONCILIATIONS + "retries.lastattempt" + TOTAL_SUFFIX; + private static final String RECONCILIATIONS_STARTED = + PREFIX + RECONCILIATIONS + "started" + TOTAL_SUFFIX; + + private static final String CONTROLLERS = "controllers."; + + private static final String CONTROLLERS_SUCCESSFUL_EXECUTION = + PREFIX + CONTROLLERS + SUCCESS_SUFFIX + TOTAL_SUFFIX; + private static final String CONTROLLERS_FAILED_EXECUTION = + PREFIX + CONTROLLERS + FAILURE_SUFFIX + TOTAL_SUFFIX; + + private static final String RECONCILIATIONS_EXECUTIONS_GAUGE = + PREFIX + RECONCILIATIONS + "executions"; + private static final String RECONCILIATIONS_QUEUE_SIZE_GAUGE = + PREFIX + RECONCILIATIONS + "active"; + + private static final String RECONCILIATION_EXECUTION_DURATION = + PREFIX + RECONCILIATIONS + "execution.seconds"; + private final MeterRegistry registry; private final Map gauges = new ConcurrentHashMap<>(); + private final Map executionTimers = new ConcurrentHashMap<>(); private final Consumer timerConfig; - /** - * Creates a MicrometerMetricsV2 instance configured to not collect per-resource metrics, just - * aggregates per resource **type** - * - * @param registry the {@link MeterRegistry} instance to use for metrics recording - * @return a MicrometerMetricsV2 instance configured to not collect per-resource metrics - */ - public static MicrometerMetricsV2 withoutPerResourceMetrics(MeterRegistry registry) { - return new MicrometerMetricsV2(registry, false, null); - } - - /** - * Creates a new builder to configure how the eventual MicrometerMetricsV2 instance will behave. - * - * @param registry the {@link MeterRegistry} instance to use for metrics recording - * @return a MicrometerMetricsV2 instance configured to not collect per-resource metrics - * @see MicrometerMetricsBuilder - */ - public static MicrometerMetricsBuilder newMicrometerMetricsBuilder(MeterRegistry registry) { - return new MicrometerMetricsBuilder(registry); - } - /** * Creates a new builder to configure how the eventual MicrometerMetricsV2 instance will behave, * pre-configuring it to collect metrics per resource. * * @param registry the {@link MeterRegistry} instance to use for metrics recording * @return a MicrometerMetricsV2 instance configured to not collect per-resource metrics - * @see PerResourceCollectingMicrometerMetricsBuilder + * @see MicrometerMetricsV2Builder */ - public static PerResourceCollectingMicrometerMetricsBuilder - newPerResourceCollectingMicrometerMetricsBuilder(MeterRegistry registry) { - return new PerResourceCollectingMicrometerMetricsBuilder(registry, null); + public static MicrometerMetricsV2Builder newPerResourceCollectingMicrometerMetricsBuilder( + MeterRegistry registry) { + return new MicrometerMetricsV2Builder(registry); } - + /** * Creates a micrometer-based Metrics implementation. * * @param registry the {@link MeterRegistry} instance to use for metrics recording - * @param collectingPerResourceMetrics whether to collect per resource metrics * @param timerConfig optional configuration for timers, defaults to publishing percentiles 0.5, * 0.95, 0.99 and histogram */ - private MicrometerMetricsV2( - MeterRegistry registry, - boolean collectingPerResourceMetrics, - Consumer timerConfig) { + private MicrometerMetricsV2(MeterRegistry registry, Consumer timerConfig) { this.registry = registry; - this.collectPerResourceMetrics = collectingPerResourceMetrics; this.timerConfig = timerConfig != null ? timerConfig @@ -135,41 +113,32 @@ private MicrometerMetricsV2( public void controllerRegistered(Controller controller) { final var configuration = controller.getConfiguration(); final var name = configuration.getName(); - final var executingThreadsRefName = reconciliationExecutionGaugeRefName(name); - final var resourceClass = configuration.getResourceClass(); + final var executingThreadsRefName = reconciliationExecutionGaugeRefKey(name); final var tags = new ArrayList(); - tags.add(Tag.of(CONTROLLER_NAME, name)); - addGVKTags(GroupVersionKind.gvkFor(resourceClass), tags, false); + addControllerName(name, tags); AtomicInteger executingThreads = - registry.gauge(RECONCILIATIONS_EXECUTIONS, tags, new AtomicInteger(0)); + registry.gauge(RECONCILIATIONS_EXECUTIONS_GAUGE, tags, new AtomicInteger(0)); gauges.put(executingThreadsRefName, executingThreads); - final var controllerQueueRefName = controllerQueueSizeGaugeRefName(name); + final var controllerQueueRefName = controllerQueueSizeGaugeRefKey(name); AtomicInteger controllerQueueSize = - registry.gauge(RECONCILIATIONS_QUEUE_SIZE, tags, new AtomicInteger(0)); + registry.gauge(RECONCILIATIONS_QUEUE_SIZE_GAUGE, tags, new AtomicInteger(0)); gauges.put(controllerQueueRefName, controllerQueueSize); - } - - private static @NonNull String reconciliationExecutionGaugeRefName(String controllerName) { - return RECONCILIATIONS_EXECUTIONS + "." + controllerName; - } - private static @NonNull String controllerQueueSizeGaugeRefName(String controllerName) { - return RECONCILIATIONS_QUEUE_SIZE + "." + controllerName; + final var timerBuilder = Timer.builder(RECONCILIATION_EXECUTION_DURATION).tags(tags); + timerConfig.accept(timerBuilder); + var timer = timerBuilder.register(registry); + executionTimers.put(name, timer); } // todo does it make sense to have both controller and reconciler execution counters? @Override public T timeControllerExecution(ControllerExecution execution) { final var name = execution.controllerName(); - final var resourceID = execution.resourceID(); - final var metadata = execution.metadata(); - final var tags = new ArrayList(16); - tags.add(Tag.of(CONTROLLER, name)); - addMetadataTags(resourceID, metadata, tags, true); - final var timerBuilder = Timer.builder(RECONCILIATION_EXECUTION_TIME).tags(tags); - timerConfig.accept(timerBuilder); - final var timer = timerBuilder.register(registry); + final var tags = new ArrayList(1); + addControllerName(name, tags); + + final var timer = executionTimers.get(name); try { final var result = timer.record( @@ -180,10 +149,10 @@ public T timeControllerExecution(ControllerExecution execution) { throw new OperatorException(e); } }); - registry.counter(CONTROLLERS_SUCCESSFUL_EXECUTION, CONTROLLER, name).increment(); + registry.counter(CONTROLLERS_SUCCESSFUL_EXECUTION, CONTROLLER_NAME, name).increment(); return result; } catch (Exception e) { - registry.counter(CONTROLLERS_FAILED_EXECUTION, CONTROLLER, name).increment(); + registry.counter(CONTROLLERS_FAILED_EXECUTION, CONTROLLER_NAME, name).increment(); throw e; } } @@ -192,14 +161,12 @@ public T timeControllerExecution(ControllerExecution execution) { public void receivedEvent(Event event, Map metadata) { if (event instanceof ResourceEvent resourceEvent) { incrementCounter( - event.getRelatedCustomResourceID(), EVENTS_RECEIVED, metadata, Tag.of(EVENT, event.getClass().getSimpleName()), Tag.of(ACTION, resourceEvent.getAction().toString())); } else { incrementCounter( - event.getRelatedCustomResourceID(), EVENTS_RECEIVED, metadata, Tag.of(EVENT, event.getClass().getSimpleName()), @@ -209,169 +176,103 @@ public void receivedEvent(Event event, Map metadata) { @Override public void cleanupDoneFor(ResourceID resourceID, Map metadata) { - incrementCounter(resourceID, EVENTS_DELETE, metadata); + incrementCounter(EVENTS_DELETE, metadata); } @Override - public void reconcileCustomResource( + public void submittedForReconciliation( HasMetadata resource, RetryInfo retryInfoNullable, Map metadata) { Optional retryInfo = Optional.ofNullable(retryInfoNullable); - ResourceID resourceID = ResourceID.fromResource(resource); // Record the counter without retry tags - incrementCounter(resourceID, RECONCILIATIONS_STARTED, metadata); + incrementCounter(RECONCILIATIONS_STARTED, metadata); - // todo add metric with for resources in exhaisted retry - // Update retry number gauge int retryNumber = retryInfo.map(RetryInfo::getAttemptCount).orElse(0); - updateGauge(resourceID, metadata, RECONCILIATIONS_RETRIES_NUMBER, retryNumber); + if (retryNumber > 0) { + incrementCounter(RECONCILIATIONS_RETRIES_NUMBER, metadata); + } + retryInfo.ifPresent( + i -> { + if (retryInfoNullable.isLastAttempt()) { + incrementCounter(RECONCILIATIONS_RETRIES_LAST_ATTEMPT, metadata); + } + }); var controllerQueueSize = - gauges.get(controllerQueueSizeGaugeRefName(metadata.get(CONTROLLER_NAME).toString())); + gauges.get(controllerQueueSizeGaugeRefKey(getControllerName(metadata))); controllerQueueSize.incrementAndGet(); } @Override public void successfullyFinishedReconciliation( HasMetadata resource, Map metadata) { - ResourceID resourceID = ResourceID.fromResource(resource); - incrementCounter(resourceID, RECONCILIATIONS_SUCCESS, metadata); - - // Reset retry gauges on successful reconciliation - updateGauge(resourceID, metadata, RECONCILIATIONS_RETRIES_NUMBER, 0); + incrementCounter(RECONCILIATIONS_SUCCESS, metadata); } @Override public void reconciliationExecutionStarted(HasMetadata resource, Map metadata) { var reconcilerExecutions = - gauges.get(reconciliationExecutionGaugeRefName(metadata.get(CONTROLLER_NAME).toString())); + gauges.get(reconciliationExecutionGaugeRefKey(getControllerName(metadata))); reconcilerExecutions.incrementAndGet(); } @Override public void reconciliationExecutionFinished(HasMetadata resource, Map metadata) { var reconcilerExecutions = - gauges.get(reconciliationExecutionGaugeRefName(metadata.get(CONTROLLER_NAME).toString())); + gauges.get(reconciliationExecutionGaugeRefKey(metadata.get(CONTROLLER_NAME).toString())); reconcilerExecutions.decrementAndGet(); var controllerQueueSize = - gauges.get(controllerQueueSizeGaugeRefName(metadata.get(CONTROLLER_NAME).toString())); + gauges.get(controllerQueueSizeGaugeRefKey(metadata.get(CONTROLLER_NAME).toString())); controllerQueueSize.decrementAndGet(); } @Override public void failedReconciliation( HasMetadata resource, Exception exception, Map metadata) { - incrementCounter(ResourceID.fromResource(resource), RECONCILIATIONS_FAILED, metadata); - } - - @Override - public > T monitorSizeOf(T map, String name) { - return registry.gaugeMapSize(PREFIX + name + SIZE_SUFFIX, Collections.emptyList(), map); - } - - private void addMetadataTags( - ResourceID resourceID, Map metadata, List tags, boolean prefixed) { - if (collectPerResourceMetrics) { - addTag(NAME, resourceID.getName(), tags, prefixed); - addTagOmittingOnEmptyValue(NAMESPACE, resourceID.getNamespace().orElse(null), tags, prefixed); - } - addTag(SCOPE, getScope(resourceID), tags, prefixed); - final var gvk = (GroupVersionKind) metadata.get(Constants.RESOURCE_GVK_KEY); - if (gvk != null) { - addGVKTags(gvk, tags, prefixed); - } - } - - private static void addTag(String name, String value, List tags, boolean prefixed) { - tags.add(Tag.of(getPrefixedMetadataTag(name, prefixed), value)); + incrementCounter(RECONCILIATIONS_FAILED, metadata); } - private static void addTagOmittingOnEmptyValue( - String name, String value, List tags, boolean prefixed) { - if (value != null && !value.isBlank()) { - addTag(name, value, tags, prefixed); - } - } - - private static String getPrefixedMetadataTag(String tagName, boolean prefixed) { - return prefixed ? METADATA_PREFIX + tagName : tagName; + private static void addTag(String name, String value, List tags) { + tags.add(Tag.of(name, value)); } - private static String getScope(ResourceID resourceID) { - return resourceID.getNamespace().isPresent() ? NAMESPACE : CLUSTER; + private static void addControllerName(Map metadata, List tags) { + addTag(CONTROLLER_NAME, getControllerName(metadata), tags); } - private static void addGVKTags(GroupVersionKind gvk, List tags, boolean prefixed) { - addTagOmittingOnEmptyValue(GROUP, gvk.getGroup(), tags, prefixed); - addTag(VERSION, gvk.getVersion(), tags, prefixed); - addTag(KIND, gvk.getKind(), tags, prefixed); + private static void addControllerName(String name, List tags) { + addTag(CONTROLLER_NAME, name, tags); } private void incrementCounter( - ResourceID id, String counterName, Map metadata, Tag... additionalTags) { - final var additionalTagsNb = - additionalTags != null && additionalTags.length > 0 ? additionalTags.length : 0; - final var metadataNb = metadata != null ? metadata.size() : 0; - final var tags = new ArrayList(6 + additionalTagsNb + metadataNb); - addMetadataTags(id, metadata, tags, false); - if (additionalTagsNb > 0) { + String counterName, Map metadata, Tag... additionalTags) { + + final var tags = new ArrayList(1 + additionalTags.length); + addControllerName(metadata, tags); + if (additionalTags.length > 0) { tags.addAll(List.of(additionalTags)); } - - final var counter = registry.counter(counterName, tags); - counter.increment(); + registry.counter(counterName, tags).increment(); } - private void updateGauge( - ResourceID id, Map metadata, String gaugeName, int value) { - final var tags = new ArrayList(6); - addMetadataTags(id, metadata, tags, false); - - AtomicInteger gauge = - gauges.computeIfAbsent( - gaugeName, key -> registry.gauge(gaugeName, tags, new AtomicInteger(0))); - gauge.set(value); + private static @NonNull String reconciliationExecutionGaugeRefKey(String controllerName) { + return RECONCILIATIONS_EXECUTIONS_GAUGE + "." + controllerName; } - public static class PerResourceCollectingMicrometerMetricsBuilder - extends MicrometerMetricsBuilder { - - private PerResourceCollectingMicrometerMetricsBuilder( - MeterRegistry registry, Consumer timerConfig) { - super(registry); - this.executionTimerConfig = timerConfig; - } - - /** - * Configures the Timer used for timing controller executions. By default, timers are configured - * to publish percentiles 0.5, 0.95, 0.99 and a percentile histogram. You can set: {@code - * .minimumExpectedValue(Duration.ofMillis(...)).maximumExpectedValue(Duration.ofSeconds(...)) } - * so micrometer can create the buckets for you. - * - * @param executionTimerConfig a consumer that will configure the Timer.Builder. The builder - * will already have the metric name and tags set. - * @return this builder for method chaining - */ - @Override - public PerResourceCollectingMicrometerMetricsBuilder withExecutionTimerConfig( - Consumer executionTimerConfig) { - this.executionTimerConfig = executionTimerConfig; - return this; - } + private static @NonNull String controllerQueueSizeGaugeRefKey(String controllerName) { + return RECONCILIATIONS_QUEUE_SIZE_GAUGE + "." + controllerName; + } - @Override - public MicrometerMetricsV2 build() { - return new MicrometerMetricsV2(registry, true, executionTimerConfig); - } + public static String getControllerName(Map metadata) { + return (String) metadata.get(Constants.CONTROLLER_NAME); } - public static class MicrometerMetricsBuilder { + public static class MicrometerMetricsV2Builder { protected final MeterRegistry registry; - private boolean collectingPerResourceMetrics = true; protected Consumer executionTimerConfig = null; - private MicrometerMetricsBuilder(MeterRegistry registry) { + public MicrometerMetricsV2Builder(MeterRegistry registry) { this.registry = registry; } @@ -383,31 +284,14 @@ private MicrometerMetricsBuilder(MeterRegistry registry) { * will already have the metric name and tags set. * @return this builder for method chaining */ - public MicrometerMetricsBuilder withExecutionTimerConfig( + public MicrometerMetricsV2Builder withExecutionTimerConfig( Consumer executionTimerConfig) { this.executionTimerConfig = executionTimerConfig; return this; } - /** Configures the instance to collect metrics on a per-resource basis. */ - @SuppressWarnings("unused") - public PerResourceCollectingMicrometerMetricsBuilder collectingMetricsPerResource() { - collectingPerResourceMetrics = true; - return new PerResourceCollectingMicrometerMetricsBuilder(registry, executionTimerConfig); - } - - /** - * Configures the instance to only collect metrics per resource **type**, in an aggregate - * fashion, instead of per resource instance. - */ - @SuppressWarnings("unused") - public MicrometerMetricsBuilder notCollectingMetricsPerResource() { - collectingPerResourceMetrics = false; - return this; - } - public MicrometerMetricsV2 build() { - return new MicrometerMetricsV2(registry, collectingPerResourceMetrics, executionTimerConfig); + return new MicrometerMetricsV2(registry, executionTimerConfig); } } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java index 4e3540bf55..1764390d6f 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java @@ -114,6 +114,7 @@ public T timeControllerExecution(ControllerExecution execution) throws Ex } @Override + @Deprecated(forRemoval = true) public > T monitorSizeOf(T map, String name) { metricsList.forEach(metrics -> metrics.monitorSizeOf(map, name)); return map; diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java index 976254f62b..12578ead24 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java @@ -50,15 +50,30 @@ default void controllerRegistered(Controller controller) default void receivedEvent(Event event, Map metadata) {} /** - * Called right before a resource is dispatched to the ExecutorService for reconciliation. - * + * @deprecated use {@link Metrics#submittedForReconciliation(HasMetadata, RetryInfo, Map)} Called + * right before a resource is dispatched to the ExecutorService for reconciliation. * @param resource the associated with the resource * @param retryInfo the current retry state information for the reconciliation request * @param metadata metadata associated with the resource being processed */ + @Deprecated(forRemoval = true) default void reconcileCustomResource( + HasMetadata resource, RetryInfo retryInfo, Map metadata) { + submittedForReconciliation(resource, retryInfo, metadata); + } + + /** + * Called right before a resource is submitted to the ExecutorService for reconciliation. + * + * @param resource the associated with the resource + * @param retryInfo the current retry state information for the reconciliation request + * @param metadata metadata associated with the resource being processed + */ + default void submittedForReconciliation( HasMetadata resource, RetryInfo retryInfo, Map metadata) {} + default void reconciliationExecutionStarted(HasMetadata resource, Map metadata) {} + /** * Called when a precedent reconciliation for the resource associated with the specified {@link * ResourceID} resulted in the provided exception, resulting in a retry of the reconciliation. @@ -70,8 +85,24 @@ default void reconcileCustomResource( default void failedReconciliation( HasMetadata resource, Exception exception, Map metadata) {} - default void reconciliationExecutionStarted(HasMetadata resource, Map metadata) {} + /** + * Called when the {@link + * io.javaoperatorsdk.operator.api.reconciler.Reconciler#reconcile(HasMetadata, Context)} method + * of the Reconciler associated with the resource associated with the specified {@link ResourceID} + * has successfully finished. + * + * @param resource the {@link ResourceID} associated with the resource being processed + * @param metadata metadata associated with the resource being processed + */ + default void successfullyFinishedReconciliation( + HasMetadata resource, Map metadata) {} + /** + * Always called not only if successfully finished. + * + * @param resource the {@link ResourceID} associated with the resource being processed + * @param metadata metadata associated with the resource being processed + */ default void reconciliationExecutionFinished( HasMetadata resource, Map metadata) {} @@ -94,18 +125,6 @@ default void finishedReconciliation(HasMetadata resource, Map me successfullyFinishedReconciliation(resource, metadata); } - /** - * Called when the {@link - * io.javaoperatorsdk.operator.api.reconciler.Reconciler#reconcile(HasMetadata, Context)} method - * of the Reconciler associated with the resource associated with the specified {@link ResourceID} - * has sucessfully finished. - * - * @param resource the {@link ResourceID} associated with the resource being processed - * @param metadata metadata associated with the resource being processed - */ - default void successfullyFinishedReconciliation( - HasMetadata resource, Map metadata) {} - /** * Encapsulates the information about a controller execution i.e. a call to either {@link * io.javaoperatorsdk.operator.api.reconciler.Reconciler#reconcile(HasMetadata, Context)} or @@ -196,6 +215,7 @@ default T timeControllerExecution(ControllerExecution execution) throws E * @param the type of the Map being monitored */ @SuppressWarnings("unused") + @Deprecated(forRemoval = true) default > T monitorSizeOf(T map, String name) { return map; } 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 4ff482f03e..e36ea9c600 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 @@ -292,13 +292,12 @@ synchronized void eventProcessingFinished( return; } cleanupOnSuccessfulExecution(executionScope); - metrics.successfullyFinishedReconciliation(executionScope.getResource(), metricsMetadata); + metrics.finishedReconciliation(executionScope.getResource(), metricsMetadata); if ((triggerOnAllEvents() && executionScope.isDeleteEvent()) || (!triggerOnAllEvents() && state.deleteEventPresent())) { cleanupForDeletedEvent(executionScope.getResourceID()); } else if (postExecutionControl.isFinalizerRemoved()) { state.markProcessedMarkForDeletion(); - metrics.cleanupDoneFor(resourceID, metricsMetadata); } else { if (state.eventPresent() || isTriggerOnAllEventAndDeleteEventPresent(state)) { log.debug("Submitting for reconciliation."); @@ -372,20 +371,18 @@ private void handleRetryOnException(ExecutionScope

executionScope, Exception state.eventPresent() || (triggerOnAllEvents() && state.isAdditionalEventPresentAfterDeleteEvent()); state.markEventReceived(triggerOnAllEvents()); - retryAwareErrorLogging(state.getRetry(), eventPresent, exception, executionScope); + metrics.failedReconciliation(executionScope.getResource(), exception, metricsMetadata); if (eventPresent) { log.debug("New events exists for for resource id: {}", resourceID); submitReconciliationExecution(state); return; } Optional nextDelay = state.getRetry().nextDelay(); - nextDelay.ifPresentOrElse( delay -> { log.debug( "Scheduling timer event for retry with delay:{} for resource: {}", delay, resourceID); - metrics.failedReconciliation(executionScope.getResource(), exception, metricsMetadata); retryEventSource().scheduleOnce(resourceID, delay); }, () -> { diff --git a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperator.java b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperator.java index c734e60345..3e8a9df13f 100644 --- a/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperator.java +++ b/sample-operators/mysql-schema/src/main/java/io/javaoperatorsdk/operator/sample/MySQLSchemaOperator.java @@ -42,7 +42,8 @@ public static void main(String[] args) throws IOException { new Operator( overrider -> overrider.withMetrics( - MicrometerMetricsV2.withoutPerResourceMetrics(new LoggingMeterRegistry()))); + new MicrometerMetricsV2.MicrometerMetricsV2Builder(new LoggingMeterRegistry()) + .build())); MySQLSchemaReconciler schemaReconciler = new MySQLSchemaReconciler(); diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java index e2f6a4fca6..3166f84220 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java @@ -56,10 +56,6 @@ public class WebPageOperator { public static void main(String[] args) throws IOException { log.info("WebServer Operator starting!"); - // TODO // todo change: - // operator_sdk_reconciliations_queue_size_webpagestandalonedependentsreconciler - // operator_sdk_reconciliations_executions_webpagestandalonedependentsreconciler - // => controller name as label // TODO add test for checking if there are metrics in prometheus // Load configuration from config.yaml Metrics metrics = initOTLPMetrics(); @@ -145,7 +141,6 @@ public Duration step() { // new UptimeMetrics().bindTo(compositeRegistry); return MicrometerMetricsV2.newPerResourceCollectingMicrometerMetricsBuilder(compositeRegistry) - .collectingMetricsPerResource() .build(); } From 5c662ddc1a24ab46cede830683b682afaa5b2754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 10 Feb 2026 21:14:53 +0100 Subject: [PATCH 43/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../josdk-operator-metrics-dashboard.json | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/observability/josdk-operator-metrics-dashboard.json b/observability/josdk-operator-metrics-dashboard.json index 0ec869978e..41916bbb97 100644 --- a/observability/josdk-operator-metrics-dashboard.json +++ b/observability/josdk-operator-metrics-dashboard.json @@ -103,8 +103,8 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(operator_sdk_reconciliations_started_total{service_name=\"josdk\"}[5m])) by (kind, version)", - "legendFormat": "{{kind}} ({{version}})", + "expr": "sum(rate(operator_sdk_reconciliations_started_total{service_name=\"josdk\"}[5m])) by (controller_name)", + "legendFormat": "{{controller_name}}", "range": true, "refId": "A" } @@ -224,8 +224,8 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(operator_sdk_reconciliations_success_total{service_name=\"josdk\"}[5m]))", - "legendFormat": "Success", + "expr": "sum(rate(operator_sdk_reconciliations_success_total{service_name=\"josdk\"}[5m])) by (controller_name)", + "legendFormat": "Success - {{controller_name}}", "range": true, "refId": "A" }, @@ -235,8 +235,8 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(operator_sdk_reconciliations_failed_total{service_name=\"josdk\"}[5m]))", - "legendFormat": "Failure", + "expr": "sum(rate(operator_sdk_reconciliations_failure_total{service_name=\"josdk\"}[5m])) by (controller_name)", + "legendFormat": "Failure - {{controller_name}}", "range": true, "refId": "B" } @@ -495,7 +495,7 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(operator_sdk_reconciliations_failed_total{service_name=\"josdk\"}[5m]))", + "expr": "sum(rate(operator_sdk_reconciliations_failure_total{service_name=\"josdk\"}[5m]))", "legendFormat": "Error Rate", "range": true, "refId": "A" @@ -585,8 +585,8 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "histogram_quantile(0.50, sum(rate(operator_sdk_reconciliations_execution_duration_seconds_bucket{service_name=\"josdk\"}[5m])) by (le, controller))", - "legendFormat": "p50 - {{controller}}", + "expr": "histogram_quantile(0.50, sum(rate(operator_sdk_reconciliations_execution_seconds_bucket{service_name=\"josdk\"}[5m])) by (le, controller_name))", + "legendFormat": "p50 - {{controller_name}}", "range": true, "refId": "A" }, @@ -596,8 +596,8 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "histogram_quantile(0.95, sum(rate(operator_sdk_reconciliations_execution_duration_seconds_bucket{service_name=\"josdk\"}[5m])) by (le, controller))", - "legendFormat": "p95 - {{controller}}", + "expr": "histogram_quantile(0.95, sum(rate(operator_sdk_reconciliations_execution_seconds_bucket{service_name=\"josdk\"}[5m])) by (le, controller_name))", + "legendFormat": "p95 - {{controller_name}}", "range": true, "refId": "B" }, @@ -607,8 +607,8 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "histogram_quantile(0.99, sum(rate(operator_sdk_reconciliations_execution_duration_seconds_bucket{service_name=\"josdk\"}[5m])) by (le, controller))", - "legendFormat": "p99 - {{controller}}", + "expr": "histogram_quantile(0.99, sum(rate(operator_sdk_reconciliations_execution_seconds_bucket{service_name=\"josdk\"}[5m])) by (le, controller_name))", + "legendFormat": "p99 - {{controller_name}}", "range": true, "refId": "C" } @@ -711,7 +711,7 @@ "type": "prometheus", "uid": "prometheus" }, - "description": "Failures by exception type", + "description": "Failures by controller", "fieldConfig": { "defaults": { "color": { @@ -787,13 +787,13 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(operator_sdk_reconciliations_failed_total{service_name=\"josdk\"}[5m])) by (exception)", - "legendFormat": "{{exception}}", + "expr": "sum(rate(operator_sdk_reconciliations_failure_total{service_name=\"josdk\"}[5m])) by (controller_name)", + "legendFormat": "{{controller_name}}", "range": true, "refId": "A" } ], - "title": "Failures by Exception Type", + "title": "Failures by Controller", "type": "timeseries" }, { @@ -877,8 +877,8 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(operator_sdk_controllers_success_total{service_name=\"josdk\"}[5m])) by (controller)", - "legendFormat": "Success - {{controller}}", + "expr": "sum(rate(operator_sdk_controllers_success_total{service_name=\"josdk\"}[5m])) by (controller_name)", + "legendFormat": "Success - {{controller_name}}", "range": true, "refId": "A" }, @@ -888,8 +888,8 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(operator_sdk_controllers_failure_total{service_name=\"josdk\"}[5m])) by (controller)", - "legendFormat": "Failure - {{controller}}", + "expr": "sum(rate(operator_sdk_controllers_failure_total{service_name=\"josdk\"}[5m])) by (controller_name)", + "legendFormat": "Failure - {{controller_name}}", "range": true, "refId": "B" } @@ -978,8 +978,8 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "sum(rate(operator_sdk_events_delete_total{service_name=\"josdk\"}[5m])) by (kind, version)", - "legendFormat": "{{kind}} ({{version}})", + "expr": "sum(rate(operator_sdk_events_delete_total{service_name=\"josdk\"}[5m])) by (controller_name)", + "legendFormat": "{{controller_name}}", "range": true, "refId": "A" } @@ -992,7 +992,7 @@ "type": "prometheus", "uid": "prometheus" }, - "description": "Current retry attempt number for resources being retried", + "description": "Rate of retry attempts", "fieldConfig": { "defaults": { "color": { @@ -1076,13 +1076,13 @@ "uid": "prometheus" }, "editorMode": "code", - "expr": "operator_sdk_reconciliations_retries_number{service_name=\"josdk\"}", - "legendFormat": "{{kind}}/{{name}} ({{namespace}})", + "expr": "sum(rate(operator_sdk_reconciliations_retries_total{service_name=\"josdk\"}[5m])) by (controller_name)", + "legendFormat": "Retries - {{controller_name}}", "range": true, "refId": "A" } ], - "title": "Reconciliation Retry Attempts", + "title": "Reconciliation Retry Rate", "type": "timeseries" } ], From c0bed1426c69ad7b51f4c85ff497155149588421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 11 Feb 2026 09:14:48 +0100 Subject: [PATCH 44/49] wip 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 | 1 + .../micrometer/MicrometerMetricsV2.java | 2 + .../kubernetes/GroupVersionKindPlural.java | 2 +- .../metrics-processing/k8s/operator.yaml | 117 ++++++++++++++ .../metrics-processing/k8s/webpage.yaml | 34 ++++ .../metrics-processing/k8s/webpage2.yaml | 34 ++++ sample-operators/metrics-processing/pom.xml | 130 +++++++++++++++ .../metrics/MetricsHandlingReconciler.java | 48 ++++++ .../MetricsHandlingSampleOperator.java | 150 ++++++++++++++++++ .../metrics/customresource/WebPage.java | 31 ++++ .../metrics/customresource/WebPageSpec.java | 44 +++++ .../metrics/customresource/WebPageStatus.java | 65 ++++++++ .../operator/sample/deployment.yaml | 42 +++++ .../operator/sample/ingress.yaml | 33 ++++ .../operator/sample/service.yaml | 28 ++++ .../src/main/resources/log4j2.xml | 30 ++++ .../src/main/resources/otlp-config.yaml | 23 +++ sample-operators/pom.xml | 1 + sample-operators/webpage/pom.xml | 21 --- .../operator/sample/WebPageOperator.java | 112 +------------ 20 files changed, 815 insertions(+), 133 deletions(-) create mode 100644 sample-operators/metrics-processing/k8s/operator.yaml create mode 100644 sample-operators/metrics-processing/k8s/webpage.yaml create mode 100644 sample-operators/metrics-processing/k8s/webpage2.yaml create mode 100644 sample-operators/metrics-processing/pom.xml create mode 100644 sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler.java create mode 100644 sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java create mode 100644 sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPage.java create mode 100644 sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageSpec.java create mode 100644 sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageStatus.java create mode 100644 sample-operators/metrics-processing/src/main/resources/io/javaoperatorsdk/operator/sample/deployment.yaml create mode 100644 sample-operators/metrics-processing/src/main/resources/io/javaoperatorsdk/operator/sample/ingress.yaml create mode 100644 sample-operators/metrics-processing/src/main/resources/io/javaoperatorsdk/operator/sample/service.yaml create mode 100644 sample-operators/metrics-processing/src/main/resources/log4j2.xml create mode 100644 sample-operators/metrics-processing/src/main/resources/otlp-config.yaml diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 172f28e3f9..edfd310aed 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -25,6 +25,7 @@ jobs: - "sample-operators/tomcat-operator" - "sample-operators/webpage" - "sample-operators/leader-election" + - "sample-operators/metrics-processing" runs-on: ubuntu-latest steps: - name: Checkout diff --git a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java index 9b75845776..0a1254ad16 100644 --- a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java +++ b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java @@ -191,6 +191,8 @@ public void submittedForReconciliation( if (retryNumber > 0) { incrementCounter(RECONCILIATIONS_RETRIES_NUMBER, metadata); } + + // todo having a gauge for the number of exhausted retries? retryInfo.ifPresent( i -> { if (retryInfoNullable.isLastAttempt()) { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GroupVersionKindPlural.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GroupVersionKindPlural.java index 4818760888..569526f4e3 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GroupVersionKindPlural.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GroupVersionKindPlural.java @@ -119,7 +119,7 @@ public static GroupVersionKindPlural gvkFor(Class resourc * @return the default plural form for the specified kind */ public static String getDefaultPluralFor(String kind) { - // todo: replace by Fabric8 version when available, see + // replace by Fabric8 version when available, see // https://github.com/fabric8io/kubernetes-client/pull/6314 return kind != null ? Pluralize.toPlural(kind.toLowerCase()) : null; } diff --git a/sample-operators/metrics-processing/k8s/operator.yaml b/sample-operators/metrics-processing/k8s/operator.yaml new file mode 100644 index 0000000000..2f2484561c --- /dev/null +++ b/sample-operators/metrics-processing/k8s/operator.yaml @@ -0,0 +1,117 @@ +# +# Copyright Java Operator SDK Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: ServiceAccount +metadata: + name: webpage-operator + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: webpage-operator +spec: + selector: + matchLabels: + app: webpage-operator + replicas: 1 + template: + metadata: + labels: + app: webpage-operator + spec: + serviceAccountName: webpage-operator + containers: + - name: operator + image: webpage-operator + imagePullPolicy: Never + ports: + - containerPort: 80 + startupProbe: + httpGet: + path: /startup + port: 8080 + initialDelaySeconds: 1 + periodSeconds: 2 + timeoutSeconds: 1 + failureThreshold: 10 + livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 5 + timeoutSeconds: 1 + periodSeconds: 2 + failureThreshold: 3 + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: operator-admin +subjects: +- kind: ServiceAccount + name: webpage-operator + namespace: default +roleRef: + kind: ClusterRole + name: webpage-operator + apiGroup: "" + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: webpage-operator +rules: +- apiGroups: + - "" + resources: + - deployments + - services + - configmaps + - pods + verbs: + - '*' +- apiGroups: + - "apps" + resources: + - deployments + - services + - configmaps + verbs: + - '*' +- apiGroups: + - "apiextensions.k8s.io" + resources: + - customresourcedefinitions + verbs: + - '*' +- apiGroups: + - "sample.javaoperatorsdk" + resources: + - webpages + - webpages/status + verbs: + - '*' +- apiGroups: + - "networking.k8s.io" + resources: + - ingresses + verbs: + - '*' + diff --git a/sample-operators/metrics-processing/k8s/webpage.yaml b/sample-operators/metrics-processing/k8s/webpage.yaml new file mode 100644 index 0000000000..6a70b51282 --- /dev/null +++ b/sample-operators/metrics-processing/k8s/webpage.yaml @@ -0,0 +1,34 @@ +# +# Copyright Java Operator SDK Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: "sample.javaoperatorsdk/v1" +kind: WebPage +metadata: +# Use labels to match the resource with different reconciler implementations: +# labels: +# low-level: "true" + name: hellows +spec: + exposed: false + html: | + + + Hello Operator World + + + Hello World! + + diff --git a/sample-operators/metrics-processing/k8s/webpage2.yaml b/sample-operators/metrics-processing/k8s/webpage2.yaml new file mode 100644 index 0000000000..e9ae5ab19e --- /dev/null +++ b/sample-operators/metrics-processing/k8s/webpage2.yaml @@ -0,0 +1,34 @@ +# +# Copyright Java Operator SDK Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: "sample.javaoperatorsdk/v1" +kind: WebPage +metadata: +# Use labels to match the resource with different reconciler implementations: +# labels: +# low-level: "true" + name: hellows2 +spec: + exposed: false + html: | + + + Hello Operator World + + + Hello World! + + diff --git a/sample-operators/metrics-processing/pom.xml b/sample-operators/metrics-processing/pom.xml new file mode 100644 index 0000000000..9a327370b3 --- /dev/null +++ b/sample-operators/metrics-processing/pom.xml @@ -0,0 +1,130 @@ + + + + 4.0.0 + + + io.javaoperatorsdk + sample-operators + 5.3.0-SNAPSHOT + + + sample-metrics-proricessing + jar + Operator SDK - Samples - Metrics processing + Showcases to handle metrics setup and deploys related tooling and dashboards + + + + + io.javaoperatorsdk + operator-framework-bom + ${project.version} + pom + import + + + io.micrometer + micrometer-bom + ${micrometer-core.version} + pom + import + + + + + + + io.javaoperatorsdk + operator-framework + + + io.javaoperatorsdk + micrometer-support + + + io.micrometer + micrometer-registry-otlp + ${micrometer-core.version} + + + org.yaml + snakeyaml + 2.3 + + + org.apache.logging.log4j + log4j-slf4j2-impl + + + org.apache.logging.log4j + log4j-core + compile + + + org.takes + takes + 1.25.0 + + + org.awaitility + awaitility + compile + + + io.javaoperatorsdk + operator-framework-junit + test + + + + + + com.google.cloud.tools + jib-maven-plugin + ${jib-maven-plugin.version} + + + gcr.io/distroless/java17-debian11 + + + webpage-operator + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + io.fabric8 + crd-generator-maven-plugin + ${fabric8-client.version} + + + + generate + + + + + + + + diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler.java new file mode 100644 index 0000000000..c80faf00a0 --- /dev/null +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler.java @@ -0,0 +1,48 @@ +/* + * 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; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.*; +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.sample.customresource.WebPage; + +@ControllerConfiguration +public class MetricsHandlingReconciler implements Reconciler { + + public static final String INDEX_HTML = "index.html"; + + private static final Logger log = LoggerFactory.getLogger(MetricsHandlingReconciler.class); + + public MetricsHandlingReconciler() {} + + @Override + public List> prepareEventSources(EventSourceContext context) { + return List.of(); + } + + @Override + public UpdateControl reconcile(WebPage webPage, Context context) { + + return UpdateControl.noUpdate(); + } +} diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java new file mode 100644 index 0000000000..053f031cc0 --- /dev/null +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java @@ -0,0 +1,150 @@ +/* + * 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; + +import java.io.IOException; +import java.io.InputStream; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.yaml.snakeyaml.Yaml; + +import io.javaoperatorsdk.operator.Operator; +import io.javaoperatorsdk.operator.api.monitoring.Metrics; +import io.javaoperatorsdk.operator.monitoring.micrometer.MicrometerMetricsV2; +import io.micrometer.core.instrument.Clock; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.composite.CompositeMeterRegistry; +import io.micrometer.core.instrument.logging.LoggingMeterRegistry; +import io.micrometer.core.instrument.logging.LoggingRegistryConfig; +import io.micrometer.registry.otlp.OtlpConfig; +import io.micrometer.registry.otlp.OtlpMeterRegistry; + +public class MetricsHandlingSampleOperator { + + private static final Logger log = LoggerFactory.getLogger(MetricsHandlingSampleOperator.class); + + /** + * Based on env variables a different flavor of Reconciler is used, showcasing how the same logic + * can be implemented using the low level and higher level APIs. + */ + public static void main(String[] args) throws IOException { + log.info("WebServer Operator starting!"); + + // TODO add test for checking if there are metrics in prometheus + // Load configuration from config.yaml + Metrics metrics = initOTLPMetrics(); + Operator operator = + new Operator(o -> o.withStopOnInformerErrorDuringStartup(false).withMetrics(metrics)); + + operator.start(); + } + + private static @NonNull Metrics initOTLPMetrics() { + CompositeMeterRegistry compositeRegistry = new CompositeMeterRegistry(); + + // Add OTLP registry + Map configProperties = loadConfigFromYaml(); + var otlpConfig = + new OtlpConfig() { + @Override + public String prefix() { + return ""; + } + + @Override + public @Nullable String get(String key) { + return configProperties.get(key); + } + + // these should come from env variables + @Override + public Map resourceAttributes() { + return Map.of("service.name", "josdk", "operator", "webpage"); + } + }; + + MeterRegistry otlpRegistry = new OtlpMeterRegistry(otlpConfig, Clock.SYSTEM); + compositeRegistry.add(otlpRegistry); + + // Add console logging registry if enabled (for development) + // String enableConsoleLogging = System.getenv("METRICS_CONSOLE_LOGGING"); + // todo remove + String enableConsoleLogging = "true"; + if ("true".equalsIgnoreCase(enableConsoleLogging)) { + log.info("Console metrics logging enabled"); + LoggingMeterRegistry loggingRegistry = + new LoggingMeterRegistry( + new LoggingRegistryConfig() { + @Override + public String get(String key) { + return null; + } + + @Override + public Duration step() { + return Duration.ofSeconds(10); // Log metrics every 30 seconds + } + }, + Clock.SYSTEM); + compositeRegistry.add(loggingRegistry); + } + + // Register JVM and system metrics + log.info("Registering JVM and system metrics..."); + // todo add back + // new JvmMemoryMetrics().bindTo(compositeRegistry); + // new JvmGcMetrics().bindTo(compositeRegistry); + // new JvmThreadMetrics().bindTo(compositeRegistry); + // new ClassLoaderMetrics().bindTo(compositeRegistry); + // new ProcessorMetrics().bindTo(compositeRegistry); + // new UptimeMetrics().bindTo(compositeRegistry); + + return MicrometerMetricsV2.newPerResourceCollectingMicrometerMetricsBuilder(compositeRegistry) + .build(); + } + + @SuppressWarnings("unchecked") + private static Map loadConfigFromYaml() { + Map configMap = new HashMap<>(); + try (InputStream inputStream = + MetricsHandlingSampleOperator.class.getResourceAsStream("/otlp-config.yaml")) { + if (inputStream == null) { + log.warn("otlp-config.yaml not found in resources, using default OTLP configuration"); + return configMap; + } + + Yaml yaml = new Yaml(); + Map yamlData = yaml.load(inputStream); + + // Navigate to otlp section and map properties directly + Map otlp = (Map) yamlData.get("otlp"); + if (otlp != null) { + otlp.forEach((key, value) -> configMap.put("otlp." + key, value.toString())); + } + + log.info("Loaded OTLP configuration from otlp-config.yaml: {}", configMap); + } catch (IOException e) { + log.error("Error loading otlp-config.yaml", e); + } + return configMap; + } +} diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPage.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPage.java new file mode 100644 index 0000000000..10d7a9cf43 --- /dev/null +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPage.java @@ -0,0 +1,31 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.sample.customresource; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +public class WebPage extends CustomResource implements Namespaced { + + @Override + public String toString() { + return "WebPage{" + "spec=" + spec + ", status=" + status + '}'; + } +} diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageSpec.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageSpec.java new file mode 100644 index 0000000000..ef70acea26 --- /dev/null +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageSpec.java @@ -0,0 +1,44 @@ +/* + * 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.customresource; + +public class WebPageSpec { + + private String html; + private Boolean exposed = false; + + public String getHtml() { + return html; + } + + public void setHtml(String html) { + this.html = html; + } + + public Boolean getExposed() { + return exposed; + } + + public WebPageSpec setExposed(Boolean exposed) { + this.exposed = exposed; + return this; + } + + @Override + public String toString() { + return "WebPageSpec{" + "html='" + html + '\'' + '}'; + } +} diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageStatus.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageStatus.java new file mode 100644 index 0000000000..76de64e645 --- /dev/null +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageStatus.java @@ -0,0 +1,65 @@ +/* + * 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.customresource; + +public class WebPageStatus { + + private String htmlConfigMap; + + private Boolean areWeGood; + + private String errorMessage; + + public String getHtmlConfigMap() { + return htmlConfigMap; + } + + public void setHtmlConfigMap(String htmlConfigMap) { + this.htmlConfigMap = htmlConfigMap; + } + + public Boolean getAreWeGood() { + return areWeGood; + } + + public void setAreWeGood(Boolean areWeGood) { + this.areWeGood = areWeGood; + } + + public String getErrorMessage() { + return errorMessage; + } + + public WebPageStatus setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + return this; + } + + @Override + public String toString() { + return "WebPageStatus{" + + "htmlConfigMap='" + + htmlConfigMap + + '\'' + + ", areWeGood='" + + areWeGood + + '\'' + + ", errorMessage='" + + errorMessage + + '\'' + + '}'; + } +} diff --git a/sample-operators/metrics-processing/src/main/resources/io/javaoperatorsdk/operator/sample/deployment.yaml b/sample-operators/metrics-processing/src/main/resources/io/javaoperatorsdk/operator/sample/deployment.yaml new file mode 100644 index 0000000000..3cbbd83222 --- /dev/null +++ b/sample-operators/metrics-processing/src/main/resources/io/javaoperatorsdk/operator/sample/deployment.yaml @@ -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. +# + +apiVersion: apps/v1 # for versions before 1.9.0 use apps/v1beta2 +kind: Deployment +metadata: + name: "" +spec: + selector: + matchLabels: + app: "" + replicas: 1 + template: + metadata: + labels: + app: "" + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 + volumeMounts: + - name: html-volume + mountPath: /usr/share/nginx/html + volumes: + - name: html-volume + configMap: + name: "" \ No newline at end of file diff --git a/sample-operators/metrics-processing/src/main/resources/io/javaoperatorsdk/operator/sample/ingress.yaml b/sample-operators/metrics-processing/src/main/resources/io/javaoperatorsdk/operator/sample/ingress.yaml new file mode 100644 index 0000000000..b037c75dda --- /dev/null +++ b/sample-operators/metrics-processing/src/main/resources/io/javaoperatorsdk/operator/sample/ingress.yaml @@ -0,0 +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. +# + +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: "" + annotations: + nginx.ingress.kubernetes.io/rewrite-target: /$1 +spec: + rules: + - http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: "" + port: + number: 80 \ No newline at end of file diff --git a/sample-operators/metrics-processing/src/main/resources/io/javaoperatorsdk/operator/sample/service.yaml b/sample-operators/metrics-processing/src/main/resources/io/javaoperatorsdk/operator/sample/service.yaml new file mode 100644 index 0000000000..8131b24cb3 --- /dev/null +++ b/sample-operators/metrics-processing/src/main/resources/io/javaoperatorsdk/operator/sample/service.yaml @@ -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. +# + +apiVersion: v1 +kind: Service +metadata: + name: "" +spec: + selector: + app: "" + ports: + - protocol: TCP + port: 80 + targetPort: 80 + type: NodePort \ No newline at end of file diff --git a/sample-operators/metrics-processing/src/main/resources/log4j2.xml b/sample-operators/metrics-processing/src/main/resources/log4j2.xml new file mode 100644 index 0000000000..7cced1edbd --- /dev/null +++ b/sample-operators/metrics-processing/src/main/resources/log4j2.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sample-operators/metrics-processing/src/main/resources/otlp-config.yaml b/sample-operators/metrics-processing/src/main/resources/otlp-config.yaml new file mode 100644 index 0000000000..17d773eb70 --- /dev/null +++ b/sample-operators/metrics-processing/src/main/resources/otlp-config.yaml @@ -0,0 +1,23 @@ +# +# 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. +# + +otlp: + # OTLP Collector endpoint - see observability/install-observability.sh for setup + url: "http://localhost:4318/v1/metrics" +# url: "http://otel-collector-collector.observability.svc.cluster.local:4318/v1/metrics" + step: 15s + batchSize: 15000 + aggregationTemporality: "cumulative" diff --git a/sample-operators/pom.xml b/sample-operators/pom.xml index 6079d3bb71..d9a9c61f4d 100644 --- a/sample-operators/pom.xml +++ b/sample-operators/pom.xml @@ -35,5 +35,6 @@ mysql-schema leader-election controller-namespace-deletion + metrics-processing diff --git a/sample-operators/webpage/pom.xml b/sample-operators/webpage/pom.xml index f8c79cf268..6ec60340ae 100644 --- a/sample-operators/webpage/pom.xml +++ b/sample-operators/webpage/pom.xml @@ -39,13 +39,6 @@ pom import - - io.micrometer - micrometer-bom - ${micrometer-core.version} - pom - import - @@ -54,20 +47,6 @@ io.javaoperatorsdk operator-framework - - io.javaoperatorsdk - micrometer-support - - - io.micrometer - micrometer-registry-otlp - ${micrometer-core.version} - - - org.yaml - snakeyaml - 2.3 - org.apache.logging.log4j log4j-slf4j2-impl diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java index 3166f84220..5366dc2e9a 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageOperator.java @@ -16,30 +16,14 @@ package io.javaoperatorsdk.operator.sample; import java.io.IOException; -import java.io.InputStream; import java.net.InetSocketAddress; -import java.time.Duration; -import java.util.HashMap; -import java.util.Map; -import org.jspecify.annotations.NonNull; -import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.yaml.snakeyaml.Yaml; import io.javaoperatorsdk.operator.Operator; -import io.javaoperatorsdk.operator.api.monitoring.Metrics; -import io.javaoperatorsdk.operator.monitoring.micrometer.MicrometerMetricsV2; import io.javaoperatorsdk.operator.sample.probes.LivenessHandler; import io.javaoperatorsdk.operator.sample.probes.StartupHandler; -import io.micrometer.core.instrument.Clock; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.composite.CompositeMeterRegistry; -import io.micrometer.core.instrument.logging.LoggingMeterRegistry; -import io.micrometer.core.instrument.logging.LoggingRegistryConfig; -import io.micrometer.registry.otlp.OtlpConfig; -import io.micrometer.registry.otlp.OtlpMeterRegistry; import com.sun.net.httpserver.HttpServer; @@ -56,12 +40,7 @@ public class WebPageOperator { public static void main(String[] args) throws IOException { log.info("WebServer Operator starting!"); - // TODO add test for checking if there are metrics in prometheus - // Load configuration from config.yaml - Metrics metrics = initOTLPMetrics(); - Operator operator = - new Operator(o -> o.withStopOnInformerErrorDuringStartup(false).withMetrics(metrics)); - + Operator operator = new Operator(o -> o.withStopOnInformerErrorDuringStartup(false)); String reconcilerEnvVar = System.getenv(WEBPAGE_RECONCILER_ENV); if (WEBPAGE_CLASSIC_RECONCILER_ENV_VALUE.equals(reconcilerEnvVar)) { operator.register(new WebPageReconciler()); @@ -79,93 +58,4 @@ public static void main(String[] args) throws IOException { server.setExecutor(null); server.start(); } - - private static @NonNull Metrics initOTLPMetrics() { - CompositeMeterRegistry compositeRegistry = new CompositeMeterRegistry(); - - // Add OTLP registry - Map configProperties = loadConfigFromYaml(); - var otlpConfig = - new OtlpConfig() { - @Override - public String prefix() { - return ""; - } - - @Override - public @Nullable String get(String key) { - return configProperties.get(key); - } - - // these should come from env variables - @Override - public Map resourceAttributes() { - return Map.of("service.name", "josdk", "operator", "webpage"); - } - }; - - MeterRegistry otlpRegistry = new OtlpMeterRegistry(otlpConfig, Clock.SYSTEM); - compositeRegistry.add(otlpRegistry); - - // Add console logging registry if enabled (for development) - // String enableConsoleLogging = System.getenv("METRICS_CONSOLE_LOGGING"); - // todo remove - String enableConsoleLogging = "true"; - if ("true".equalsIgnoreCase(enableConsoleLogging)) { - log.info("Console metrics logging enabled"); - LoggingMeterRegistry loggingRegistry = - new LoggingMeterRegistry( - new LoggingRegistryConfig() { - @Override - public String get(String key) { - return null; - } - - @Override - public Duration step() { - return Duration.ofSeconds(10); // Log metrics every 30 seconds - } - }, - Clock.SYSTEM); - compositeRegistry.add(loggingRegistry); - } - - // Register JVM and system metrics - log.info("Registering JVM and system metrics..."); - // todo add back - // new JvmMemoryMetrics().bindTo(compositeRegistry); - // new JvmGcMetrics().bindTo(compositeRegistry); - // new JvmThreadMetrics().bindTo(compositeRegistry); - // new ClassLoaderMetrics().bindTo(compositeRegistry); - // new ProcessorMetrics().bindTo(compositeRegistry); - // new UptimeMetrics().bindTo(compositeRegistry); - - return MicrometerMetricsV2.newPerResourceCollectingMicrometerMetricsBuilder(compositeRegistry) - .build(); - } - - @SuppressWarnings("unchecked") - private static Map loadConfigFromYaml() { - Map configMap = new HashMap<>(); - try (InputStream inputStream = WebPageOperator.class.getResourceAsStream("/otlp-config.yaml")) { - if (inputStream == null) { - log.warn("otlp-config.yaml not found in resources, using default OTLP configuration"); - return configMap; - } - - Yaml yaml = new Yaml(); - Map yamlData = yaml.load(inputStream); - - // Navigate to otlp section and map properties directly - Map otlp = (Map) yamlData.get("otlp"); - if (otlp != null) { - otlp.forEach((key, value) -> configMap.put("otlp." + key, value.toString())); - } - - log.info("Loaded OTLP configuration from otlp-config.yaml: {}", configMap); - } catch (IOException e) { - log.error("Error loading otlp-config.yaml", e); - } - return configMap; - } } From 19b68cb8eb5eff3d2e606f4732471dc42e7358c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 11 Feb 2026 09:19:17 +0100 Subject: [PATCH 45/49] 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/metrics/MetricsHandlingReconciler.java | 4 ++-- .../sample/metrics/MetricsHandlingSampleOperator.java | 2 +- .../operator/sample/metrics/customresource/WebPage.java | 2 +- .../operator/sample/metrics/customresource/WebPageSpec.java | 2 +- .../operator/sample/metrics/customresource/WebPageStatus.java | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler.java index c80faf00a0..979567b0c0 100644 --- a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler.java +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.sample; +package io.javaoperatorsdk.operator.sample.metrics; import java.util.List; @@ -24,7 +24,7 @@ import io.javaoperatorsdk.operator.api.reconciler.*; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.processing.event.source.EventSource; -import io.javaoperatorsdk.operator.sample.customresource.WebPage; +import io.javaoperatorsdk.operator.sample.metrics.customresource.WebPage; @ControllerConfiguration public class MetricsHandlingReconciler implements Reconciler { diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java index 053f031cc0..05259fe8a9 100644 --- a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.sample; +package io.javaoperatorsdk.operator.sample.metrics; import java.io.IOException; import java.io.InputStream; diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPage.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPage.java index 10d7a9cf43..3cbfdcc891 100644 --- a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPage.java +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPage.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.sample.customresource; +package io.javaoperatorsdk.operator.sample.metrics.customresource; import io.fabric8.kubernetes.api.model.Namespaced; import io.fabric8.kubernetes.client.CustomResource; diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageSpec.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageSpec.java index ef70acea26..5786bac357 100644 --- a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageSpec.java +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageSpec.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.sample.customresource; +package io.javaoperatorsdk.operator.sample.metrics.customresource; public class WebPageSpec { diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageStatus.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageStatus.java index 76de64e645..22e776a73d 100644 --- a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageStatus.java +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageStatus.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.sample.customresource; +package io.javaoperatorsdk.operator.sample.metrics.customresource; public class WebPageStatus { From a419dfb745c0d51635f72d79decaec282f1bdc34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 11 Feb 2026 15:40:03 +0100 Subject: [PATCH 46/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../micrometer/MicrometerMetrics.java | 6 +- .../micrometer/MicrometerMetricsV2.java | 15 +---- .../api/monitoring/AggregatedMetrics.java | 11 ++-- .../operator/api/monitoring/Metrics.java | 11 +++- .../processing/event/EventProcessor.java | 6 +- .../api/monitoring/AggregatedMetricsTest.java | 17 +++-- .../metrics-processing/k8s/webpage.yaml | 34 ---------- .../metrics-processing/k8s/webpage2.yaml | 34 ---------- ...r.java => MetricsHandlingReconciler1.java} | 17 +++-- .../metrics/MetricsHandlingReconciler2.java | 54 +++++++++++++++ .../MetricsHandlingSampleOperator.java | 26 ++++---- ...va => MetricsHandlingCustomResource1.java} | 5 +- .../MetricsHandlingCustomResource2.java | 32 +++++++++ ...PageSpec.java => MetricsHandlingSpec.java} | 27 ++------ .../customresource/MetricsHandlingStatus.java | 29 +++++++++ .../metrics/customresource/WebPageStatus.java | 65 ------------------- .../sample/metrics/MetricsHandlingE2E.java | 56 ++++++++++++++++ sample-operators/webpage/k8s/webpage2.yaml | 34 ---------- .../src/main/resources/otlp-config.yaml | 23 ------- 19 files changed, 240 insertions(+), 262 deletions(-) delete mode 100644 sample-operators/metrics-processing/k8s/webpage.yaml delete mode 100644 sample-operators/metrics-processing/k8s/webpage2.yaml rename sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/{MetricsHandlingReconciler.java => MetricsHandlingReconciler1.java} (69%) create mode 100644 sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler2.java rename sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/{WebPage.java => MetricsHandlingCustomResource1.java} (80%) create mode 100644 sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingCustomResource2.java rename sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/{WebPageSpec.java => MetricsHandlingSpec.java} (60%) create mode 100644 sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingStatus.java delete mode 100644 sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageStatus.java create mode 100644 sample-operators/metrics-processing/src/test/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingE2E.java delete mode 100644 sample-operators/webpage/k8s/webpage2.yaml delete mode 100644 sample-operators/webpage/src/main/resources/otlp-config.yaml diff --git a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java index 26971e7fa9..e44406018c 100644 --- a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java +++ b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetrics.java @@ -209,6 +209,7 @@ public void cleanupDoneFor(ResourceID resourceID, Map metadata) } @Override + @Deprecated(forRemoval = true) public void reconcileCustomResource( HasMetadata resource, RetryInfo retryInfoNullable, Map metadata) { Optional retryInfo = Optional.ofNullable(retryInfoNullable); @@ -242,7 +243,8 @@ public void reconciliationExecutionStarted(HasMetadata resource, Map metadata) { + public void reconciliationExecutionFinished( + HasMetadata resource, RetryInfo retryInfo, Map metadata) { var reconcilerExecutions = gauges.get(RECONCILIATIONS_EXECUTIONS + metadata.get(CONTROLLER_NAME)); reconcilerExecutions.decrementAndGet(); @@ -254,7 +256,7 @@ public void reconciliationExecutionFinished(HasMetadata resource, Map metadata) { + HasMetadata resource, RetryInfo retry, Exception exception, Map metadata) { var cause = exception.getCause(); if (cause == null) { cause = exception; diff --git a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java index 0a1254ad16..446df5a0dd 100644 --- a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java +++ b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java @@ -56,8 +56,6 @@ public class MicrometerMetricsV2 implements Metrics { PREFIX + RECONCILIATIONS + SUCCESS_SUFFIX + TOTAL_SUFFIX; private static final String RECONCILIATIONS_RETRIES_NUMBER = PREFIX + RECONCILIATIONS + "retries" + TOTAL_SUFFIX; - private static final String RECONCILIATIONS_RETRIES_LAST_ATTEMPT = - PREFIX + RECONCILIATIONS + "retries.lastattempt" + TOTAL_SUFFIX; private static final String RECONCILIATIONS_STARTED = PREFIX + RECONCILIATIONS + "started" + TOTAL_SUFFIX; @@ -192,14 +190,6 @@ public void submittedForReconciliation( incrementCounter(RECONCILIATIONS_RETRIES_NUMBER, metadata); } - // todo having a gauge for the number of exhausted retries? - retryInfo.ifPresent( - i -> { - if (retryInfoNullable.isLastAttempt()) { - incrementCounter(RECONCILIATIONS_RETRIES_LAST_ATTEMPT, metadata); - } - }); - var controllerQueueSize = gauges.get(controllerQueueSizeGaugeRefKey(getControllerName(metadata))); controllerQueueSize.incrementAndGet(); @@ -219,7 +209,8 @@ public void reconciliationExecutionStarted(HasMetadata resource, Map metadata) { + public void reconciliationExecutionFinished( + HasMetadata resource, RetryInfo retryInfo, Map metadata) { var reconcilerExecutions = gauges.get(reconciliationExecutionGaugeRefKey(metadata.get(CONTROLLER_NAME).toString())); reconcilerExecutions.decrementAndGet(); @@ -231,7 +222,7 @@ public void reconciliationExecutionFinished(HasMetadata resource, Map metadata) { + HasMetadata resource, RetryInfo retry, Exception exception, Map metadata) { incrementCounter(RECONCILIATIONS_FAILED, metadata); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java index 1764390d6f..396014cacc 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java @@ -83,8 +83,9 @@ public void reconcileCustomResource( @Override public void failedReconciliation( - HasMetadata resource, Exception exception, Map metadata) { - metricsList.forEach(metrics -> metrics.failedReconciliation(resource, exception, metadata)); + HasMetadata resource, RetryInfo retry, Exception exception, Map metadata) { + metricsList.forEach( + metrics -> metrics.failedReconciliation(resource, retry, exception, metadata)); } @Override @@ -93,8 +94,10 @@ public void reconciliationExecutionStarted(HasMetadata resource, Map metadata) { - metricsList.forEach(metrics -> metrics.reconciliationExecutionFinished(resource, metadata)); + public void reconciliationExecutionFinished( + HasMetadata resource, RetryInfo retryInfo, Map metadata) { + metricsList.forEach( + metrics -> metrics.reconciliationExecutionFinished(resource, retryInfo, metadata)); } @Override diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java index 12578ead24..1f4981c226 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/Metrics.java @@ -23,6 +23,7 @@ import io.javaoperatorsdk.operator.processing.Controller; import io.javaoperatorsdk.operator.processing.event.Event; import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.retry.RetryExecution; /** * An interface that metrics providers can implement and that the SDK will call at different times @@ -79,11 +80,15 @@ default void reconciliationExecutionStarted(HasMetadata resource, Map metadata) {} + HasMetadata resource, + RetryInfo retryInfo, + Exception exception, + Map metadata) {} /** * Called when the {@link @@ -101,10 +106,12 @@ default void successfullyFinishedReconciliation( * Always called not only if successfully finished. * * @param resource the {@link ResourceID} associated with the resource being processed + * @param retryInfo not that this retry info in state after {@link RetryExecution#nextDelay()} is + * called in case of exception. * @param metadata metadata associated with the resource being processed */ default void reconciliationExecutionFinished( - HasMetadata resource, Map metadata) {} + HasMetadata resource, RetryInfo retryInfo, Map metadata) {} /** * Called when the resource associated with the specified {@link ResourceID} has been successfully 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 e36ea9c600..ae30068cf2 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 @@ -372,7 +372,8 @@ private void handleRetryOnException(ExecutionScope

executionScope, Exception || (triggerOnAllEvents() && state.isAdditionalEventPresentAfterDeleteEvent()); state.markEventReceived(triggerOnAllEvents()); retryAwareErrorLogging(state.getRetry(), eventPresent, exception, executionScope); - metrics.failedReconciliation(executionScope.getResource(), exception, metricsMetadata); + metrics.failedReconciliation( + executionScope.getResource(), state.getRetry(), exception, metricsMetadata); if (eventPresent) { log.debug("New events exists for for resource id: {}", resourceID); submitReconciliationExecution(state); @@ -547,7 +548,8 @@ public void run() { reconciliationDispatcher.handleExecution(executionScope); eventProcessingFinished(executionScope, postExecutionControl); } finally { - metrics.reconciliationExecutionFinished(executionScope.getResource(), metricsMetadata); + metrics.reconciliationExecutionFinished( + executionScope.getResource(), executionScope.getRetryInfo(), metricsMetadata); // restore original name thread.setName(name); MDCUtils.removeResourceInfo(); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetricsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetricsTest.java index 36a3ca0877..cb5788462b 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetricsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetricsTest.java @@ -97,13 +97,12 @@ void reconcileCustomResource_shouldDelegateToAllMetricsInOrder() { @Test void failedReconciliation_shouldDelegateToAllMetricsInOrder() { final var exception = new RuntimeException("Test exception"); - - aggregatedMetrics.failedReconciliation(resource, exception, metadata); + aggregatedMetrics.failedReconciliation(resource, retryInfo, exception, metadata); final var inOrder = inOrder(metrics1, metrics2, metrics3); - inOrder.verify(metrics1).failedReconciliation(resource, exception, metadata); - inOrder.verify(metrics2).failedReconciliation(resource, exception, metadata); - inOrder.verify(metrics3).failedReconciliation(resource, exception, metadata); + inOrder.verify(metrics1).failedReconciliation(resource, retryInfo, exception, metadata); + inOrder.verify(metrics2).failedReconciliation(resource, retryInfo, exception, metadata); + inOrder.verify(metrics3).failedReconciliation(resource, retryInfo, exception, metadata); verifyNoMoreInteractions(metrics1, metrics2, metrics3); } @@ -120,12 +119,12 @@ void reconciliationExecutionStarted_shouldDelegateToAllMetricsInOrder() { @Test void reconciliationExecutionFinished_shouldDelegateToAllMetricsInOrder() { - aggregatedMetrics.reconciliationExecutionFinished(resource, metadata); + aggregatedMetrics.reconciliationExecutionFinished(resource, retryInfo, metadata); final var inOrder = inOrder(metrics1, metrics2, metrics3); - inOrder.verify(metrics1).reconciliationExecutionFinished(resource, metadata); - inOrder.verify(metrics2).reconciliationExecutionFinished(resource, metadata); - inOrder.verify(metrics3).reconciliationExecutionFinished(resource, metadata); + inOrder.verify(metrics1).reconciliationExecutionFinished(resource, retryInfo, metadata); + inOrder.verify(metrics2).reconciliationExecutionFinished(resource, retryInfo, metadata); + inOrder.verify(metrics3).reconciliationExecutionFinished(resource, retryInfo, metadata); verifyNoMoreInteractions(metrics1, metrics2, metrics3); } diff --git a/sample-operators/metrics-processing/k8s/webpage.yaml b/sample-operators/metrics-processing/k8s/webpage.yaml deleted file mode 100644 index 6a70b51282..0000000000 --- a/sample-operators/metrics-processing/k8s/webpage.yaml +++ /dev/null @@ -1,34 +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. -# - -apiVersion: "sample.javaoperatorsdk/v1" -kind: WebPage -metadata: -# Use labels to match the resource with different reconciler implementations: -# labels: -# low-level: "true" - name: hellows -spec: - exposed: false - html: | - - - Hello Operator World - - - Hello World! - - diff --git a/sample-operators/metrics-processing/k8s/webpage2.yaml b/sample-operators/metrics-processing/k8s/webpage2.yaml deleted file mode 100644 index e9ae5ab19e..0000000000 --- a/sample-operators/metrics-processing/k8s/webpage2.yaml +++ /dev/null @@ -1,34 +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. -# - -apiVersion: "sample.javaoperatorsdk/v1" -kind: WebPage -metadata: -# Use labels to match the resource with different reconciler implementations: -# labels: -# low-level: "true" - name: hellows2 -spec: - exposed: false - html: | - - - Hello Operator World - - - Hello World! - - diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler1.java similarity index 69% rename from sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler.java rename to sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler1.java index 979567b0c0..a39c3994f5 100644 --- a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler.java +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler1.java @@ -20,28 +20,31 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import io.fabric8.kubernetes.api.model.*; import io.javaoperatorsdk.operator.api.reconciler.*; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.processing.event.source.EventSource; -import io.javaoperatorsdk.operator.sample.metrics.customresource.WebPage; +import io.javaoperatorsdk.operator.sample.metrics.customresource.MetricsHandlingCustomResource1; @ControllerConfiguration -public class MetricsHandlingReconciler implements Reconciler { +public class MetricsHandlingReconciler1 implements Reconciler { public static final String INDEX_HTML = "index.html"; - private static final Logger log = LoggerFactory.getLogger(MetricsHandlingReconciler.class); + private static final Logger log = LoggerFactory.getLogger(MetricsHandlingReconciler1.class); - public MetricsHandlingReconciler() {} + public MetricsHandlingReconciler1() {} @Override - public List> prepareEventSources(EventSourceContext context) { + public List> prepareEventSources( + EventSourceContext context) { + return List.of(); } @Override - public UpdateControl reconcile(WebPage webPage, Context context) { + public UpdateControl reconcile( + MetricsHandlingCustomResource1 metricsHandlingCustomResource1, + Context context) { return UpdateControl.noUpdate(); } diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler2.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler2.java new file mode 100644 index 0000000000..90e948b06c --- /dev/null +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler2.java @@ -0,0 +1,54 @@ +/* + * 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.metrics; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.sample.metrics.customresource.MetricsHandlingCustomResource1; + +@ControllerConfiguration +public class MetricsHandlingReconciler2 implements Reconciler { + + public static final String INDEX_HTML = "index.html"; + + private static final Logger log = LoggerFactory.getLogger(MetricsHandlingReconciler2.class); + + public MetricsHandlingReconciler2() {} + + @Override + public List> prepareEventSources( + EventSourceContext context) { + + return List.of(); + } + + @Override + public UpdateControl reconcile( + MetricsHandlingCustomResource1 metricsHandlingCustomResource1, + Context context) { + + return UpdateControl.noUpdate(); + } +} diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java index 05259fe8a9..741a704184 100644 --- a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java @@ -32,6 +32,12 @@ import io.javaoperatorsdk.operator.monitoring.micrometer.MicrometerMetricsV2; import io.micrometer.core.instrument.Clock; import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.jvm.ClassLoaderMetrics; +import io.micrometer.core.instrument.binder.jvm.JvmGcMetrics; +import io.micrometer.core.instrument.binder.jvm.JvmMemoryMetrics; +import io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics; +import io.micrometer.core.instrument.binder.system.ProcessorMetrics; +import io.micrometer.core.instrument.binder.system.UptimeMetrics; import io.micrometer.core.instrument.composite.CompositeMeterRegistry; import io.micrometer.core.instrument.logging.LoggingMeterRegistry; import io.micrometer.core.instrument.logging.LoggingRegistryConfig; @@ -78,16 +84,14 @@ public String prefix() { // these should come from env variables @Override public Map resourceAttributes() { - return Map.of("service.name", "josdk", "operator", "webpage"); + return Map.of("service.name", "josdk", "operator", "metrics-processing"); } }; MeterRegistry otlpRegistry = new OtlpMeterRegistry(otlpConfig, Clock.SYSTEM); compositeRegistry.add(otlpRegistry); - // Add console logging registry if enabled (for development) // String enableConsoleLogging = System.getenv("METRICS_CONSOLE_LOGGING"); - // todo remove String enableConsoleLogging = "true"; if ("true".equalsIgnoreCase(enableConsoleLogging)) { log.info("Console metrics logging enabled"); @@ -101,7 +105,7 @@ public String get(String key) { @Override public Duration step() { - return Duration.ofSeconds(10); // Log metrics every 30 seconds + return Duration.ofSeconds(15); } }, Clock.SYSTEM); @@ -110,13 +114,13 @@ public Duration step() { // Register JVM and system metrics log.info("Registering JVM and system metrics..."); - // todo add back - // new JvmMemoryMetrics().bindTo(compositeRegistry); - // new JvmGcMetrics().bindTo(compositeRegistry); - // new JvmThreadMetrics().bindTo(compositeRegistry); - // new ClassLoaderMetrics().bindTo(compositeRegistry); - // new ProcessorMetrics().bindTo(compositeRegistry); - // new UptimeMetrics().bindTo(compositeRegistry); + + new JvmMemoryMetrics().bindTo(compositeRegistry); + new JvmGcMetrics().bindTo(compositeRegistry); + new JvmThreadMetrics().bindTo(compositeRegistry); + new ClassLoaderMetrics().bindTo(compositeRegistry); + new ProcessorMetrics().bindTo(compositeRegistry); + new UptimeMetrics().bindTo(compositeRegistry); return MicrometerMetricsV2.newPerResourceCollectingMicrometerMetricsBuilder(compositeRegistry) .build(); diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPage.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingCustomResource1.java similarity index 80% rename from sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPage.java rename to sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingCustomResource1.java index 3cbfdcc891..892f663175 100644 --- a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPage.java +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingCustomResource1.java @@ -22,10 +22,11 @@ @Group("sample.javaoperatorsdk") @Version("v1") -public class WebPage extends CustomResource implements Namespaced { +public class MetricsHandlingCustomResource1 + extends CustomResource implements Namespaced { @Override public String toString() { - return "WebPage{" + "spec=" + spec + ", status=" + status + '}'; + return "MetricsHandlingCustomResource1{" + "spec=" + spec + ", status=" + status + '}'; } } diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingCustomResource2.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingCustomResource2.java new file mode 100644 index 0000000000..b60bad6920 --- /dev/null +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingCustomResource2.java @@ -0,0 +1,32 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.sample.metrics.customresource; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +public class MetricsHandlingCustomResource2 + extends CustomResource implements Namespaced { + + @Override + public String toString() { + return "MetricsHandlingCustomResource1{" + "spec=" + spec + ", status=" + status + '}'; + } +} diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageSpec.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingSpec.java similarity index 60% rename from sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageSpec.java rename to sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingSpec.java index 5786bac357..6012d0ab5f 100644 --- a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageSpec.java +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingSpec.java @@ -15,30 +15,15 @@ */ package io.javaoperatorsdk.operator.sample.metrics.customresource; -public class WebPageSpec { +public class MetricsHandlingSpec { - private String html; - private Boolean exposed = false; + private int number; - public String getHtml() { - return html; + public int getObservedNumber() { + return number; } - public void setHtml(String html) { - this.html = html; - } - - public Boolean getExposed() { - return exposed; - } - - public WebPageSpec setExposed(Boolean exposed) { - this.exposed = exposed; - return this; - } - - @Override - public String toString() { - return "WebPageSpec{" + "html='" + html + '\'' + '}'; + public void setObservedNumber(int observedNumber) { + this.number = observedNumber; } } diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingStatus.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingStatus.java new file mode 100644 index 0000000000..76c286cf80 --- /dev/null +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/MetricsHandlingStatus.java @@ -0,0 +1,29 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.sample.metrics.customresource; + +public class MetricsHandlingStatus { + + private int observedNumber; + + public int getObservedNumber() { + return observedNumber; + } + + public void setObservedNumber(int observedNumber) { + this.observedNumber = observedNumber; + } +} diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageStatus.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageStatus.java deleted file mode 100644 index 22e776a73d..0000000000 --- a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/customresource/WebPageStatus.java +++ /dev/null @@ -1,65 +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.metrics.customresource; - -public class WebPageStatus { - - private String htmlConfigMap; - - private Boolean areWeGood; - - private String errorMessage; - - public String getHtmlConfigMap() { - return htmlConfigMap; - } - - public void setHtmlConfigMap(String htmlConfigMap) { - this.htmlConfigMap = htmlConfigMap; - } - - public Boolean getAreWeGood() { - return areWeGood; - } - - public void setAreWeGood(Boolean areWeGood) { - this.areWeGood = areWeGood; - } - - public String getErrorMessage() { - return errorMessage; - } - - public WebPageStatus setErrorMessage(String errorMessage) { - this.errorMessage = errorMessage; - return this; - } - - @Override - public String toString() { - return "WebPageStatus{" - + "htmlConfigMap='" - + htmlConfigMap - + '\'' - + ", areWeGood='" - + areWeGood - + '\'' - + ", errorMessage='" - + errorMessage - + '\'' - + '}'; - } -} diff --git a/sample-operators/metrics-processing/src/test/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingE2E.java b/sample-operators/metrics-processing/src/test/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingE2E.java new file mode 100644 index 0000000000..3088a81802 --- /dev/null +++ b/sample-operators/metrics-processing/src/test/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingE2E.java @@ -0,0 +1,56 @@ +/* + * 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.metrics; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javaoperatorsdk.operator.junit.AbstractOperatorExtension; +import io.javaoperatorsdk.operator.junit.ClusterDeployedOperatorExtension; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +class MetricsHandlingE2E { + static final Logger log = LoggerFactory.getLogger(MetricsHandlingE2E.class); + + boolean isLocal() { + String deployment = System.getProperty("test.deployment"); + boolean remote = (deployment != null && deployment.equals("remote")); + log.info("Running the operator {} ", remote ? "remotely" : "locally"); + return !remote; + } + + @RegisterExtension + AbstractOperatorExtension operator = + isLocal() + ? LocallyRunOperatorExtension.builder() + .withReconciler(new MetricsHandlingReconciler1()) + .withReconciler(new MetricsHandlingReconciler2()) + // SchemaDependentResource annotation + // .withInfrastructure(infrastructure) + // .withPortForward(MY_SQL_NS, "app", "mysql", 3306, + // SchemaDependentResource.LOCAL_PORT) + .build() + : ClusterDeployedOperatorExtension.builder() + // .withOperatorDeployment(client.load(new + // FileInputStream("k8s/operator.yaml")).items()) + // .withInfrastructure(infrastructure) + .build(); + + @Test + void testPropagatedMetrics() {} +} diff --git a/sample-operators/webpage/k8s/webpage2.yaml b/sample-operators/webpage/k8s/webpage2.yaml deleted file mode 100644 index e9ae5ab19e..0000000000 --- a/sample-operators/webpage/k8s/webpage2.yaml +++ /dev/null @@ -1,34 +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. -# - -apiVersion: "sample.javaoperatorsdk/v1" -kind: WebPage -metadata: -# Use labels to match the resource with different reconciler implementations: -# labels: -# low-level: "true" - name: hellows2 -spec: - exposed: false - html: | - - - Hello Operator World - - - Hello World! - - diff --git a/sample-operators/webpage/src/main/resources/otlp-config.yaml b/sample-operators/webpage/src/main/resources/otlp-config.yaml deleted file mode 100644 index 17d773eb70..0000000000 --- a/sample-operators/webpage/src/main/resources/otlp-config.yaml +++ /dev/null @@ -1,23 +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. -# - -otlp: - # OTLP Collector endpoint - see observability/install-observability.sh for setup - url: "http://localhost:4318/v1/metrics" -# url: "http://otel-collector-collector.observability.svc.cluster.local:4318/v1/metrics" - step: 15s - batchSize: 15000 - aggregationTemporality: "cumulative" From 243e9a695f4adc869532a1f89688045485d51252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 11 Feb 2026 16:08:55 +0100 Subject: [PATCH 47/49] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../micrometer/MicrometerMetricsV2.java | 51 ++++++----- .../josdk-operator-metrics-dashboard.json | 87 +++++++++++++++++-- .../MetricsHandlingSampleOperator.java | 8 +- 3 files changed, 116 insertions(+), 30 deletions(-) diff --git a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java index 446df5a0dd..acb1b11713 100644 --- a/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java +++ b/micrometer-support/src/main/java/io/javaoperatorsdk/operator/monitoring/micrometer/MicrometerMetricsV2.java @@ -30,6 +30,7 @@ import io.javaoperatorsdk.operator.processing.Controller; import io.javaoperatorsdk.operator.processing.event.Event; import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Tag; @@ -37,12 +38,11 @@ public class MicrometerMetricsV2 implements Metrics { - private static final String PREFIX = "operator.sdk."; private static final String CONTROLLER_NAME = "controller.name"; private static final String EVENT = "event"; private static final String ACTION = "action"; - private static final String EVENTS_RECEIVED = PREFIX + "events.received"; - private static final String EVENTS_DELETE = PREFIX + "events.delete"; + private static final String EVENTS_RECEIVED = "events.received"; + private static final String EVENTS_DELETE = "events.delete"; private static final String UNKNOWN_ACTION = "UNKNOWN"; public static final String TOTAL_SUFFIX = ".total"; private static final String SUCCESS_SUFFIX = "success"; @@ -51,28 +51,26 @@ public class MicrometerMetricsV2 implements Metrics { private static final String RECONCILIATIONS = "reconciliations."; private static final String RECONCILIATIONS_FAILED = - PREFIX + RECONCILIATIONS + FAILURE_SUFFIX + TOTAL_SUFFIX; + RECONCILIATIONS + FAILURE_SUFFIX + TOTAL_SUFFIX; private static final String RECONCILIATIONS_SUCCESS = - PREFIX + RECONCILIATIONS + SUCCESS_SUFFIX + TOTAL_SUFFIX; + RECONCILIATIONS + SUCCESS_SUFFIX + TOTAL_SUFFIX; private static final String RECONCILIATIONS_RETRIES_NUMBER = - PREFIX + RECONCILIATIONS + "retries" + TOTAL_SUFFIX; - private static final String RECONCILIATIONS_STARTED = - PREFIX + RECONCILIATIONS + "started" + TOTAL_SUFFIX; + RECONCILIATIONS + "retries" + TOTAL_SUFFIX; + private static final String RECONCILIATIONS_STARTED = RECONCILIATIONS + "started" + TOTAL_SUFFIX; private static final String CONTROLLERS = "controllers."; private static final String CONTROLLERS_SUCCESSFUL_EXECUTION = - PREFIX + CONTROLLERS + SUCCESS_SUFFIX + TOTAL_SUFFIX; + CONTROLLERS + SUCCESS_SUFFIX + TOTAL_SUFFIX; private static final String CONTROLLERS_FAILED_EXECUTION = - PREFIX + CONTROLLERS + FAILURE_SUFFIX + TOTAL_SUFFIX; + CONTROLLERS + FAILURE_SUFFIX + TOTAL_SUFFIX; - private static final String RECONCILIATIONS_EXECUTIONS_GAUGE = - PREFIX + RECONCILIATIONS + "executions"; - private static final String RECONCILIATIONS_QUEUE_SIZE_GAUGE = - PREFIX + RECONCILIATIONS + "active"; + private static final String RECONCILIATIONS_EXECUTIONS_GAUGE = RECONCILIATIONS + "executions"; + private static final String RECONCILIATIONS_QUEUE_SIZE_GAUGE = RECONCILIATIONS + "active"; + private static final String NUMBER_OF_RESOURCE_GAUGE = "custom_resources"; private static final String RECONCILIATION_EXECUTION_DURATION = - PREFIX + RECONCILIATIONS + "execution.seconds"; + RECONCILIATIONS + "execution.seconds"; private final MeterRegistry registry; private final Map gauges = new ConcurrentHashMap<>(); @@ -113,7 +111,7 @@ public void controllerRegistered(Controller controller) { final var name = configuration.getName(); final var executingThreadsRefName = reconciliationExecutionGaugeRefKey(name); final var tags = new ArrayList(); - addControllerName(name, tags); + addControllerNameTag(name, tags); AtomicInteger executingThreads = registry.gauge(RECONCILIATIONS_EXECUTIONS_GAUGE, tags, new AtomicInteger(0)); gauges.put(executingThreadsRefName, executingThreads); @@ -123,18 +121,25 @@ public void controllerRegistered(Controller controller) { registry.gauge(RECONCILIATIONS_QUEUE_SIZE_GAUGE, tags, new AtomicInteger(0)); gauges.put(controllerQueueRefName, controllerQueueSize); + var numberOfResources = registry.gauge(NUMBER_OF_RESOURCE_GAUGE, tags, new AtomicInteger(0)); + gauges.put(numberOfResourcesRefName(name), numberOfResources); + final var timerBuilder = Timer.builder(RECONCILIATION_EXECUTION_DURATION).tags(tags); timerConfig.accept(timerBuilder); var timer = timerBuilder.register(registry); executionTimers.put(name, timer); } + private String numberOfResourcesRefName(String name) { + return NUMBER_OF_RESOURCE_GAUGE + name; + } + // todo does it make sense to have both controller and reconciler execution counters? @Override public T timeControllerExecution(ControllerExecution execution) { final var name = execution.controllerName(); final var tags = new ArrayList(1); - addControllerName(name, tags); + addControllerNameTag(name, tags); final var timer = executionTimers.get(name); try { @@ -158,6 +163,12 @@ public T timeControllerExecution(ControllerExecution execution) { @Override public void receivedEvent(Event event, Map metadata) { if (event instanceof ResourceEvent resourceEvent) { + if (resourceEvent.getAction() == ResourceAction.ADDED) { + gauges.get(numberOfResourcesRefName(getControllerName(metadata))).incrementAndGet(); + } + if (resourceEvent.getAction() == ResourceAction.DELETED) { + gauges.get(numberOfResourcesRefName(getControllerName(metadata))).decrementAndGet(); + } incrementCounter( EVENTS_RECEIVED, metadata, @@ -230,11 +241,11 @@ private static void addTag(String name, String value, List tags) { tags.add(Tag.of(name, value)); } - private static void addControllerName(Map metadata, List tags) { + private static void addControllerNameTag(Map metadata, List tags) { addTag(CONTROLLER_NAME, getControllerName(metadata), tags); } - private static void addControllerName(String name, List tags) { + private static void addControllerNameTag(String name, List tags) { addTag(CONTROLLER_NAME, name, tags); } @@ -242,7 +253,7 @@ private void incrementCounter( String counterName, Map metadata, Tag... additionalTags) { final var tags = new ArrayList(1 + additionalTags.length); - addControllerName(metadata, tags); + addControllerNameTag(metadata, tags); if (additionalTags.length > 0) { tags.addAll(List.of(additionalTags)); } diff --git a/observability/josdk-operator-metrics-dashboard.json b/observability/josdk-operator-metrics-dashboard.json index 41916bbb97..83acda1d8d 100644 --- a/observability/josdk-operator-metrics-dashboard.json +++ b/observability/josdk-operator-metrics-dashboard.json @@ -504,6 +504,75 @@ "title": "Error Rate", "type": "stat" }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Number of custom resources tracked by controller", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + }, + { + "color": "green", + "value": 10 + }, + { + "color": "yellow", + "value": 100 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 13, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "custom_resources{service_name=\"josdk\"}", + "legendFormat": "{{controller_name}}", + "range": true, + "refId": "A" + } + ], + "title": "Custom Resources Count", + "type": "stat" + }, { "datasource": { "type": "prometheus", @@ -562,7 +631,7 @@ "gridPos": { "h": 8, "w": 12, - "x": 0, + "x": 12, "y": 16 }, "id": 7, @@ -674,8 +743,8 @@ "gridPos": { "h": 8, "w": 12, - "x": 12, - "y": 16 + "x": 0, + "y": 24 }, "id": 8, "options": { @@ -764,7 +833,7 @@ "gridPos": { "h": 8, "w": 12, - "x": 0, + "x": 12, "y": 24 }, "id": 9, @@ -854,8 +923,8 @@ "gridPos": { "h": 8, "w": 12, - "x": 12, - "y": 24 + "x": 0, + "y": 32 }, "id": 10, "options": { @@ -955,7 +1024,7 @@ "gridPos": { "h": 8, "w": 12, - "x": 0, + "x": 12, "y": 32 }, "id": 11, @@ -1053,8 +1122,8 @@ "gridPos": { "h": 8, "w": 12, - "x": 12, - "y": 32 + "x": 0, + "y": 40 }, "id": 12, "options": { diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java index 741a704184..4719815911 100644 --- a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java @@ -47,6 +47,7 @@ public class MetricsHandlingSampleOperator { private static final Logger log = LoggerFactory.getLogger(MetricsHandlingSampleOperator.class); + public static final String OPERATOR_SDK_METRICS_PREFIX = "operator.sdk"; /** * Based on env variables a different flavor of Reconciler is used, showcasing how the same logic @@ -73,7 +74,7 @@ public static void main(String[] args) throws IOException { new OtlpConfig() { @Override public String prefix() { - return ""; + return OPERATOR_SDK_METRICS_PREFIX; } @Override @@ -98,6 +99,11 @@ public Map resourceAttributes() { LoggingMeterRegistry loggingRegistry = new LoggingMeterRegistry( new LoggingRegistryConfig() { + @Override + public String prefix() { + return OPERATOR_SDK_METRICS_PREFIX; + } + @Override public String get(String key) { return null; From 7acaaf468f527d12adc33b71c7485e7dbad641e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 11 Feb 2026 16:56:31 +0100 Subject: [PATCH 48/49] e2e test skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../metrics-processing/k8s/operator.yaml | 63 +--- .../k8s/test-resource1-fail.yaml | 22 ++ .../k8s/test-resource1-success.yaml | 22 ++ .../k8s/test-resource2-error.yaml | 22 ++ .../k8s/test-resource2-success.yaml | 22 ++ .../metrics/MetricsHandlingReconciler1.java | 39 ++- .../metrics/MetricsHandlingReconciler2.java | 51 ++- .../MetricsHandlingSampleOperator.java | 6 +- .../sample/metrics/MetricsHandlingE2E.java | 299 +++++++++++++++++- 9 files changed, 465 insertions(+), 81 deletions(-) create mode 100644 sample-operators/metrics-processing/k8s/test-resource1-fail.yaml create mode 100644 sample-operators/metrics-processing/k8s/test-resource1-success.yaml create mode 100644 sample-operators/metrics-processing/k8s/test-resource2-error.yaml create mode 100644 sample-operators/metrics-processing/k8s/test-resource2-success.yaml diff --git a/sample-operators/metrics-processing/k8s/operator.yaml b/sample-operators/metrics-processing/k8s/operator.yaml index 2f2484561c..336d587a5b 100644 --- a/sample-operators/metrics-processing/k8s/operator.yaml +++ b/sample-operators/metrics-processing/k8s/operator.yaml @@ -17,84 +17,55 @@ apiVersion: v1 kind: ServiceAccount metadata: - name: webpage-operator + name: metrics-processing-operator --- apiVersion: apps/v1 kind: Deployment metadata: - name: webpage-operator + name: metrics-processing-operator spec: selector: matchLabels: - app: webpage-operator + app: metrics-processing-operator replicas: 1 template: metadata: labels: - app: webpage-operator + app: metrics-processing-operator spec: - serviceAccountName: webpage-operator + serviceAccountName: metrics-processing-operator containers: - name: operator - image: webpage-operator + image: metrics-processing-operator imagePullPolicy: Never - ports: - - containerPort: 80 - startupProbe: - httpGet: - path: /startup - port: 8080 - initialDelaySeconds: 1 - periodSeconds: 2 - timeoutSeconds: 1 - failureThreshold: 10 - livenessProbe: - httpGet: - path: /healthz - port: 8080 - initialDelaySeconds: 5 - timeoutSeconds: 1 - periodSeconds: 2 - failureThreshold: 3 --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: - name: operator-admin + name: metrics-processing-operator-admin subjects: - kind: ServiceAccount - name: webpage-operator + name: metrics-processing-operator namespace: default roleRef: kind: ClusterRole - name: webpage-operator - apiGroup: "" + name: metrics-processing-operator + apiGroup: rbac.authorization.k8s.io --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - name: webpage-operator + name: metrics-processing-operator rules: - apiGroups: - "" resources: - - deployments - - services - - configmaps - pods verbs: - '*' -- apiGroups: - - "apps" - resources: - - deployments - - services - - configmaps - verbs: - - '*' - apiGroups: - "apiextensions.k8s.io" resources: @@ -104,14 +75,10 @@ rules: - apiGroups: - "sample.javaoperatorsdk" resources: - - webpages - - webpages/status + - metricshandlingcustomresource1s + - metricshandlingcustomresource1s/status + - metricshandlingcustomresource2s + - metricshandlingcustomresource2s/status verbs: - '*' -- apiGroups: - - "networking.k8s.io" - resources: - - ingresses - verbs: - - '*' diff --git a/sample-operators/metrics-processing/k8s/test-resource1-fail.yaml b/sample-operators/metrics-processing/k8s/test-resource1-fail.yaml new file mode 100644 index 0000000000..7e536ffdda --- /dev/null +++ b/sample-operators/metrics-processing/k8s/test-resource1-fail.yaml @@ -0,0 +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. +# + +apiVersion: sample.javaoperatorsdk/v1 +kind: MetricsHandlingCustomResource1 +metadata: + name: test-fail-1 +spec: + number: 100 diff --git a/sample-operators/metrics-processing/k8s/test-resource1-success.yaml b/sample-operators/metrics-processing/k8s/test-resource1-success.yaml new file mode 100644 index 0000000000..19d1cd5c9d --- /dev/null +++ b/sample-operators/metrics-processing/k8s/test-resource1-success.yaml @@ -0,0 +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. +# + +apiVersion: sample.javaoperatorsdk/v1 +kind: MetricsHandlingCustomResource1 +metadata: + name: test-success-1 +spec: + number: 42 diff --git a/sample-operators/metrics-processing/k8s/test-resource2-error.yaml b/sample-operators/metrics-processing/k8s/test-resource2-error.yaml new file mode 100644 index 0000000000..81793031b8 --- /dev/null +++ b/sample-operators/metrics-processing/k8s/test-resource2-error.yaml @@ -0,0 +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. +# + +apiVersion: sample.javaoperatorsdk/v1 +kind: MetricsHandlingCustomResource2 +metadata: + name: test-error-2 +spec: + number: 200 diff --git a/sample-operators/metrics-processing/k8s/test-resource2-success.yaml b/sample-operators/metrics-processing/k8s/test-resource2-success.yaml new file mode 100644 index 0000000000..f68b4e37f6 --- /dev/null +++ b/sample-operators/metrics-processing/k8s/test-resource2-success.yaml @@ -0,0 +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. +# + +apiVersion: sample.javaoperatorsdk/v1 +kind: MetricsHandlingCustomResource2 +metadata: + name: test-success-2 +spec: + number: 77 diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler1.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler1.java index a39c3994f5..c6ae3ea1ea 100644 --- a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler1.java +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler1.java @@ -24,12 +24,11 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.processing.event.source.EventSource; import io.javaoperatorsdk.operator.sample.metrics.customresource.MetricsHandlingCustomResource1; +import io.javaoperatorsdk.operator.sample.metrics.customresource.MetricsHandlingStatus; @ControllerConfiguration public class MetricsHandlingReconciler1 implements Reconciler { - public static final String INDEX_HTML = "index.html"; - private static final Logger log = LoggerFactory.getLogger(MetricsHandlingReconciler1.class); public MetricsHandlingReconciler1() {} @@ -37,15 +36,43 @@ public MetricsHandlingReconciler1() {} @Override public List> prepareEventSources( EventSourceContext context) { - return List.of(); } @Override public UpdateControl reconcile( - MetricsHandlingCustomResource1 metricsHandlingCustomResource1, - Context context) { + MetricsHandlingCustomResource1 resource, Context context) { + + String name = resource.getMetadata().getName(); + log.info("Reconciling resource: {}", name); + + // Simulate some work + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted during reconciliation", e); + } + + // Throw exception for resources with names containing "fail" or "error" + if (name.toLowerCase().contains("fail") || name.toLowerCase().contains("error")) { + log.error("Simulating failure for resource: {}", name); + throw new IllegalStateException("Simulated reconciliation failure for resource: " + name); + } + + // Update status + var status = resource.getStatus(); + if (status == null) { + status = new MetricsHandlingStatus(); + resource.setStatus(status); + } + + var spec = resource.getSpec(); + if (spec != null) { + status.setObservedNumber(spec.getObservedNumber()); + } - return UpdateControl.noUpdate(); + log.info("Successfully reconciled resource: {}", name); + return UpdateControl.patchStatus(resource); } } diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler2.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler2.java index 90e948b06c..f0f8f58cde 100644 --- a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler2.java +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingReconciler2.java @@ -15,40 +15,57 @@ */ package io.javaoperatorsdk.operator.sample.metrics; -import java.util.List; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; -import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; -import io.javaoperatorsdk.operator.processing.event.source.EventSource; -import io.javaoperatorsdk.operator.sample.metrics.customresource.MetricsHandlingCustomResource1; +import io.javaoperatorsdk.operator.sample.metrics.customresource.MetricsHandlingCustomResource2; +import io.javaoperatorsdk.operator.sample.metrics.customresource.MetricsHandlingStatus; @ControllerConfiguration -public class MetricsHandlingReconciler2 implements Reconciler { - - public static final String INDEX_HTML = "index.html"; +public class MetricsHandlingReconciler2 implements Reconciler { private static final Logger log = LoggerFactory.getLogger(MetricsHandlingReconciler2.class); public MetricsHandlingReconciler2() {} @Override - public List> prepareEventSources( - EventSourceContext context) { + public UpdateControl reconcile( + MetricsHandlingCustomResource2 resource, Context context) { - return List.of(); - } + String name = resource.getMetadata().getName(); + log.info("Reconciling resource: {}", name); - @Override - public UpdateControl reconcile( - MetricsHandlingCustomResource1 metricsHandlingCustomResource1, - Context context) { + // Simulate some work (slightly different timing than reconciler1) + try { + Thread.sleep(150); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted during reconciliation", e); + } + + // Throw exception for resources with names containing "fail" or "error" + if (name.toLowerCase().contains("fail") || name.toLowerCase().contains("error")) { + log.error("Simulating failure for resource: {}", name); + throw new IllegalStateException("Simulated reconciliation failure for resource: " + name); + } + + // Update status + var status = resource.getStatus(); + if (status == null) { + status = new MetricsHandlingStatus(); + resource.setStatus(status); + } + + var spec = resource.getSpec(); + if (spec != null) { + status.setObservedNumber(spec.getObservedNumber()); + } - return UpdateControl.noUpdate(); + log.info("Successfully reconciled resource: {}", name); + return UpdateControl.patchStatus(resource); } } diff --git a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java index 4719815911..0db8172c32 100644 --- a/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java +++ b/sample-operators/metrics-processing/src/main/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingSampleOperator.java @@ -54,14 +54,16 @@ public class MetricsHandlingSampleOperator { * can be implemented using the low level and higher level APIs. */ public static void main(String[] args) throws IOException { - log.info("WebServer Operator starting!"); + log.info("Metrics Handling Sample Operator starting!"); - // TODO add test for checking if there are metrics in prometheus // Load configuration from config.yaml Metrics metrics = initOTLPMetrics(); Operator operator = new Operator(o -> o.withStopOnInformerErrorDuringStartup(false).withMetrics(metrics)); + operator.register(new MetricsHandlingReconciler1()); + operator.register(new MetricsHandlingReconciler2()); + operator.start(); } diff --git a/sample-operators/metrics-processing/src/test/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingE2E.java b/sample-operators/metrics-processing/src/test/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingE2E.java index 3088a81802..0e3f7b2e07 100644 --- a/sample-operators/metrics-processing/src/test/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingE2E.java +++ b/sample-operators/metrics-processing/src/test/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingE2E.java @@ -15,17 +15,40 @@ */ package io.javaoperatorsdk.operator.sample.metrics; +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.extension.RegisterExtension; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import io.fabric8.kubernetes.api.model.Pod; +import io.fabric8.kubernetes.client.LocalPortForward; import io.javaoperatorsdk.operator.junit.AbstractOperatorExtension; import io.javaoperatorsdk.operator.junit.ClusterDeployedOperatorExtension; import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.sample.metrics.customresource.MetricsHandlingCustomResource1; +import io.javaoperatorsdk.operator.sample.metrics.customresource.MetricsHandlingCustomResource2; +import io.javaoperatorsdk.operator.sample.metrics.customresource.MetricsHandlingSpec; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +@TestInstance(TestInstance.Lifecycle.PER_CLASS) class MetricsHandlingE2E { static final Logger log = LoggerFactory.getLogger(MetricsHandlingE2E.class); + static final String OBSERVABILITY_NAMESPACE = "observability"; + static final int PROMETHEUS_PORT = 9090; + private LocalPortForward prometheusPortForward; + + MetricsHandlingE2E() throws FileNotFoundException {} boolean isLocal() { String deployment = System.getProperty("test.deployment"); @@ -40,17 +63,277 @@ boolean isLocal() { ? LocallyRunOperatorExtension.builder() .withReconciler(new MetricsHandlingReconciler1()) .withReconciler(new MetricsHandlingReconciler2()) - // SchemaDependentResource annotation - // .withInfrastructure(infrastructure) - // .withPortForward(MY_SQL_NS, "app", "mysql", 3306, - // SchemaDependentResource.LOCAL_PORT) .build() : ClusterDeployedOperatorExtension.builder() - // .withOperatorDeployment(client.load(new - // FileInputStream("k8s/operator.yaml")).items()) - // .withInfrastructure(infrastructure) + .withOperatorDeployment( + operator() + .getKubernetesClient() + .load(new FileInputStream("k8s/operator.yaml")) + .items()) .build(); + @BeforeAll + void setupObservability() { + if (!isLocal()) { + log.info("Setting up observability stack..."); + try { + // Run the install-observability.sh script + ProcessBuilder processBuilder = + new ProcessBuilder("bash", "../../observability/install-observability.sh"); + processBuilder.directory(new File("sample-operators/metrics-processing")); + processBuilder.redirectErrorStream(true); + + Process process = processBuilder.start(); + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + String line; + while ((line = reader.readLine()) != null) { + log.info("Observability setup: {}", line); + } + + int exitCode = process.waitFor(); + if (exitCode != 0) { + log.warn("Observability setup script returned exit code: {}", exitCode); + } + + // Wait for Prometheus to be ready + await() + .atMost(Duration.ofMinutes(3)) + .pollInterval(Duration.ofSeconds(5)) + .untilAsserted( + () -> { + var prometheusPod = + operator + .getKubernetesClient() + .pods() + .inNamespace(OBSERVABILITY_NAMESPACE) + .withLabel("app", "prometheus") + .list() + .getItems() + .stream() + .findFirst(); + assertThat(prometheusPod).isPresent(); + assertThat(prometheusPod.get().getStatus().getPhase()).isEqualTo("Running"); + }); + + log.info("Observability stack is ready"); + + // Setup port forwarding to Prometheus + setupPrometheusPortForward(); + + } catch (Exception e) { + log.error("Failed to setup observability stack", e); + throw new RuntimeException(e); + } + } + } + + private void setupPrometheusPortForward() { + try { + Pod prometheusPod = + operator + .getKubernetesClient() + .pods() + .inNamespace(OBSERVABILITY_NAMESPACE) + .withLabel("app", "prometheus") + .list() + .getItems() + .stream() + .findFirst() + .orElseThrow(() -> new IllegalStateException("Prometheus pod not found")); + + log.info( + "Setting up port forward to Prometheus pod: {}", prometheusPod.getMetadata().getName()); + prometheusPortForward = + operator + .getKubernetesClient() + .pods() + .inNamespace(OBSERVABILITY_NAMESPACE) + .withName(prometheusPod.getMetadata().getName()) + .portForward(PROMETHEUS_PORT); + + log.info( + "Prometheus port forward established on local port: {}", + prometheusPortForward.getLocalPort()); + + // Wait a bit for port forward to be ready + Thread.sleep(2000); + + } catch (Exception e) { + log.error("Failed to setup Prometheus port forward", e); + throw new RuntimeException(e); + } + } + + @AfterAll + void cleanup() { + if (prometheusPortForward != null) { + try { + prometheusPortForward.close(); + log.info("Closed Prometheus port forward"); + } catch (IOException e) { + log.warn("Failed to close Prometheus port forward", e); + } + } + } + @Test - void testPropagatedMetrics() {} + void testPropagatedMetrics() throws Exception { + log.info("Starting metrics propagation test"); + + // Create successful resources + MetricsHandlingCustomResource1 successResource1 = createResource1("test-success-1", 42); + operator.create(successResource1); + + MetricsHandlingCustomResource2 successResource2 = createResource2("test-success-2", 77); + operator.create(successResource2); + + // Create resources that will fail + MetricsHandlingCustomResource1 failResource1 = createResource1("test-fail-1", 100); + operator.create(failResource1); + + MetricsHandlingCustomResource2 errorResource2 = createResource2("test-error-2", 200); + operator.create(errorResource2); + + // Wait for reconciliations to happen multiple times + log.info("Waiting for reconciliations to occur..."); + Thread.sleep(10000); + + if (!isLocal()) { + // Query Prometheus to verify metrics + verifyPrometheusMetrics(); + } else { + log.info("Skipping Prometheus verification for local test"); + // For local tests, just verify that resources exist + await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + var resource = operator.get(MetricsHandlingCustomResource1.class, "test-success-1"); + assertThat(resource).isNotNull(); + assertThat(resource.getStatus()).isNotNull(); + assertThat(resource.getStatus().getObservedNumber()).isEqualTo(42); + }); + } + + log.info("Metrics propagation test completed"); + } + + private void verifyPrometheusMetrics() throws Exception { + log.info("Verifying metrics in Prometheus..."); + + int localPort = prometheusPortForward.getLocalPort(); + String prometheusUrl = "http://localhost:" + localPort; + + // Verify reconciliation started metrics + String startedQuery = "operator_sdk_reconciliations_started_total"; + await() + .atMost(Duration.ofSeconds(60)) + .pollInterval(Duration.ofSeconds(5)) + .untilAsserted( + () -> { + String result = queryPrometheus(prometheusUrl, startedQuery); + log.info("Reconciliations started metric: {}", result); + assertThat(result).contains("\"status\":\"success\""); + assertThat(result).contains("operator_sdk_reconciliations_started_total"); + }); + + // Verify success metrics + String successQuery = "operator_sdk_reconciliations_success_total"; + await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + String result = queryPrometheus(prometheusUrl, successQuery); + log.info("Reconciliations success metric: {}", result); + assertThat(result).contains("\"status\":\"success\""); + assertThat(result).contains("operator_sdk_reconciliations_success_total"); + }); + + // Verify failure metrics + String failureQuery = "operator_sdk_reconciliations_failure_total"; + await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + String result = queryPrometheus(prometheusUrl, failureQuery); + log.info("Reconciliations failure metric: {}", result); + assertThat(result).contains("\"status\":\"success\""); + assertThat(result).contains("operator_sdk_reconciliations_failure_total"); + }); + + // Verify controller execution metrics + String controllerQuery = "operator_sdk_controllers_success_total"; + await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + String result = queryPrometheus(prometheusUrl, controllerQuery); + log.info("Controller success metric: {}", result); + assertThat(result).contains("\"status\":\"success\""); + }); + + // Verify execution time metrics + String executionTimeQuery = "operator_sdk_reconciliations_execution_seconds_count"; + await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + String result = queryPrometheus(prometheusUrl, executionTimeQuery); + log.info("Execution time metric: {}", result); + assertThat(result).contains("\"status\":\"success\""); + }); + + log.info("All metrics verified successfully in Prometheus"); + } + + private String queryPrometheus(String prometheusUrl, String query) throws IOException { + String urlString = prometheusUrl + "/api/v1/query?query=" + query; + URL url = new URL(urlString); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + + int responseCode = connection.getResponseCode(); + if (responseCode != 200) { + throw new IOException("Prometheus query failed with response code: " + responseCode); + } + + try (BufferedReader in = + new BufferedReader( + new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { + StringBuilder response = new StringBuilder(); + String line; + while ((line = in.readLine()) != null) { + response.append(line); + } + return response.toString(); + } + } + + private MetricsHandlingCustomResource1 createResource1(String name, int number) { + MetricsHandlingCustomResource1 resource = new MetricsHandlingCustomResource1(); + resource.getMetadata().setName(name); + + MetricsHandlingSpec spec = new MetricsHandlingSpec(); + spec.setObservedNumber(number); + resource.setSpec(spec); + + return resource; + } + + private MetricsHandlingCustomResource2 createResource2(String name, int number) { + MetricsHandlingCustomResource2 resource = new MetricsHandlingCustomResource2(); + resource.getMetadata().setName(name); + + MetricsHandlingSpec spec = new MetricsHandlingSpec(); + spec.setObservedNumber(number); + resource.setSpec(spec); + + return resource; + } + + AbstractOperatorExtension operator() { + return operator; + } } From 5f446ac8c77acf7e51ee4de153d8be2030d7ead6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 12 Feb 2026 08:17:30 +0100 Subject: [PATCH 49/49] wip 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/otlp-config.yaml | 4 +- .../sample/metrics/MetricsHandlingE2E.java | 92 +++++++++++-------- 2 files changed, 56 insertions(+), 40 deletions(-) diff --git a/sample-operators/metrics-processing/src/main/resources/otlp-config.yaml b/sample-operators/metrics-processing/src/main/resources/otlp-config.yaml index 17d773eb70..9cc4cc4e57 100644 --- a/sample-operators/metrics-processing/src/main/resources/otlp-config.yaml +++ b/sample-operators/metrics-processing/src/main/resources/otlp-config.yaml @@ -16,8 +16,8 @@ otlp: # OTLP Collector endpoint - see observability/install-observability.sh for setup - url: "http://localhost:4318/v1/metrics" -# url: "http://otel-collector-collector.observability.svc.cluster.local:4318/v1/metrics" +# url: "http://localhost:4318/v1/metrics" + url: "http://otel-collector-collector.observability.svc.cluster.local:4318/v1/metrics" step: 15s batchSize: 15000 aggregationTemporality: "cumulative" diff --git a/sample-operators/metrics-processing/src/test/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingE2E.java b/sample-operators/metrics-processing/src/test/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingE2E.java index 0e3f7b2e07..2940e8a40e 100644 --- a/sample-operators/metrics-processing/src/test/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingE2E.java +++ b/sample-operators/metrics-processing/src/test/java/io/javaoperatorsdk/operator/sample/metrics/MetricsHandlingE2E.java @@ -74,46 +74,62 @@ boolean isLocal() { @BeforeAll void setupObservability() { - if (!isLocal()) { log.info("Setting up observability stack..."); try { - // Run the install-observability.sh script - ProcessBuilder processBuilder = - new ProcessBuilder("bash", "../../observability/install-observability.sh"); - processBuilder.directory(new File("sample-operators/metrics-processing")); - processBuilder.redirectErrorStream(true); - - Process process = processBuilder.start(); - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - String line; - while ((line = reader.readLine()) != null) { - log.info("Observability setup: {}", line); - } - - int exitCode = process.waitFor(); - if (exitCode != 0) { - log.warn("Observability setup script returned exit code: {}", exitCode); - } +// // Find the observability script relative to project root +// File projectRoot = new File(".").getCanonicalFile(); +// while (projectRoot != null && !new File(projectRoot, "observability").exists()) { +// projectRoot = projectRoot.getParentFile(); +// } +// +// if (projectRoot == null) { +// throw new IllegalStateException("Could not find observability directory"); +// } +// +// File scriptFile = new File(projectRoot, "observability/install-observability.sh"); +// if (!scriptFile.exists()) { +// throw new IllegalStateException("Observability script not found at: " + scriptFile.getAbsolutePath()); +// } +// +// log.info("Running observability setup script: {}", scriptFile.getAbsolutePath()); +// +// // Run the install-observability.sh script +// ProcessBuilder processBuilder = +// new ProcessBuilder("/bin/sh", scriptFile.getAbsolutePath()); +// processBuilder.redirectErrorStream(true); +// +// processBuilder.environment().putAll(System.getenv()); +// Process process = processBuilder.start(); +// BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); +// String line; +// while ((line = reader.readLine()) != null) { +// log.info("Observability setup: {}", line); +// } +// +// int exitCode = process.waitFor(); +// if (exitCode != 0) { +// log.warn("Observability setup script returned exit code: {}", exitCode); +// } // Wait for Prometheus to be ready await() - .atMost(Duration.ofMinutes(3)) - .pollInterval(Duration.ofSeconds(5)) - .untilAsserted( - () -> { - var prometheusPod = - operator - .getKubernetesClient() - .pods() - .inNamespace(OBSERVABILITY_NAMESPACE) - .withLabel("app", "prometheus") - .list() - .getItems() - .stream() - .findFirst(); - assertThat(prometheusPod).isPresent(); - assertThat(prometheusPod.get().getStatus().getPhase()).isEqualTo("Running"); - }); + .atMost(Duration.ofMinutes(3)) + .pollInterval(Duration.ofSeconds(5)) + .untilAsserted( + () -> { + var prometheusPod = + operator + .getKubernetesClient() + .pods() + .inNamespace(OBSERVABILITY_NAMESPACE) + .withLabel("app.kubernetes.io/name", "prometheus") + .list() + .getItems() + .stream() + .findFirst(); + assertThat(prometheusPod).isPresent(); + assertThat(prometheusPod.get().getStatus().getPhase()).isEqualTo("Running"); + }); log.info("Observability stack is ready"); @@ -124,9 +140,9 @@ void setupObservability() { log.error("Failed to setup observability stack", e); throw new RuntimeException(e); } - } - } + + } private void setupPrometheusPortForward() { try { Pod prometheusPod = @@ -134,7 +150,7 @@ private void setupPrometheusPortForward() { .getKubernetesClient() .pods() .inNamespace(OBSERVABILITY_NAMESPACE) - .withLabel("app", "prometheus") + .withLabel("app.kubernetes.io/name", "prometheus") .list() .getItems() .stream()