From 0a22c16cc87d458ff214d9b4b73fa6f95d919dd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 20 Jun 2025 17:01:48 +0200 Subject: [PATCH 01/15] feat: field selectors support for InformerEventSource (#2835) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/api/config/informer/Field.java | 10 +++ .../api/config/informer/FieldSelector.java | 26 +++++++ .../config/informer/FieldSelectorBuilder.java | 23 ++++++ .../api/config/informer/Informer.java | 3 + .../informer/InformerConfiguration.java | 24 +++++- .../InformerEventSourceConfiguration.java | 8 +- .../source/informer/InformerManager.java | 12 +++ .../fieldselector/FieldSelectorIT.java | 73 +++++++++++++++++++ .../FieldSelectorTestReconciler.java | 69 ++++++++++++++++++ 9 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Field.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/FieldSelector.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/FieldSelectorBuilder.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/fieldselector/FieldSelectorIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/fieldselector/FieldSelectorTestReconciler.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Field.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Field.java new file mode 100644 index 0000000000..3acd193cf6 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Field.java @@ -0,0 +1,10 @@ +package io.javaoperatorsdk.operator.api.config.informer; + +public @interface Field { + + String path(); + + String value(); + + boolean negated() default false; +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/FieldSelector.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/FieldSelector.java new file mode 100644 index 0000000000..412ffafdfb --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/FieldSelector.java @@ -0,0 +1,26 @@ +package io.javaoperatorsdk.operator.api.config.informer; + +import java.util.Arrays; +import java.util.List; + +public class FieldSelector { + private final List fields; + + public FieldSelector(List fields) { + this.fields = fields; + } + + public FieldSelector(Field... fields) { + this.fields = Arrays.asList(fields); + } + + public List getFields() { + return fields; + } + + public record Field(String path, String value, boolean negated) { + public Field(String path, String value) { + this(path, value, false); + } + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/FieldSelectorBuilder.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/FieldSelectorBuilder.java new file mode 100644 index 0000000000..b2cf4d0b5e --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/FieldSelectorBuilder.java @@ -0,0 +1,23 @@ +package io.javaoperatorsdk.operator.api.config.informer; + +import java.util.ArrayList; +import java.util.List; + +public class FieldSelectorBuilder { + + private final List fields = new ArrayList<>(); + + public FieldSelectorBuilder withField(String path, String value) { + fields.add(new FieldSelector.Field(path, value)); + return this; + } + + public FieldSelectorBuilder withoutField(String path, String value) { + fields.add(new FieldSelector.Field(path, value, true)); + return this; + } + + public FieldSelector build() { + return new FieldSelector(fields); + } +} 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 80a025009d..cf40da317e 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 @@ -113,4 +113,7 @@ * the informer cache. */ long informerListLimit() default NO_LONG_VALUE_SET; + + /** Kubernetes field selector for additional resource filtering */ + Field[] fieldSelector() default {}; } 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 958a2a7a6f..5fbb62daff 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 @@ -1,5 +1,6 @@ package io.javaoperatorsdk.operator.api.config.informer; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Set; @@ -36,6 +37,7 @@ public class InformerConfiguration { private GenericFilter genericFilter; private ItemStore itemStore; private Long informerListLimit; + private FieldSelector fieldSelector; protected InformerConfiguration( Class resourceClass, @@ -48,7 +50,8 @@ protected InformerConfiguration( OnDeleteFilter onDeleteFilter, GenericFilter genericFilter, ItemStore itemStore, - Long informerListLimit) { + Long informerListLimit, + FieldSelector fieldSelector) { this(resourceClass); this.name = name; this.namespaces = namespaces; @@ -60,6 +63,7 @@ protected InformerConfiguration( this.genericFilter = genericFilter; this.itemStore = itemStore; this.informerListLimit = informerListLimit; + this.fieldSelector = fieldSelector; } private InformerConfiguration(Class resourceClass) { @@ -93,7 +97,8 @@ public static InformerConfiguration.Builder builder( original.onDeleteFilter, original.genericFilter, original.itemStore, - original.informerListLimit) + original.informerListLimit, + original.fieldSelector) .builder; } @@ -264,6 +269,10 @@ public Long getInformerListLimit() { return informerListLimit; } + public FieldSelector getFieldSelector() { + return fieldSelector; + } + @SuppressWarnings("UnusedReturnValue") public class Builder { @@ -329,6 +338,12 @@ public InformerConfiguration.Builder initFromAnnotation( final var informerListLimit = informerListLimitValue == Constants.NO_LONG_VALUE_SET ? null : informerListLimitValue; withInformerListLimit(informerListLimit); + + withFieldSelector( + new FieldSelector( + Arrays.stream(informerConfig.fieldSelector()) + .map(f -> new FieldSelector.Field(f.path(), f.value(), f.negated())) + .toList())); } return this; } @@ -424,5 +439,10 @@ public Builder withInformerListLimit(Long informerListLimit) { InformerConfiguration.this.informerListLimit = informerListLimit; return this; } + + public Builder withFieldSelector(FieldSelector fieldSelector) { + InformerConfiguration.this.fieldSelector = fieldSelector; + 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 2369d5f523..6a38c59bd1 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 @@ -265,6 +265,11 @@ public Builder withInformerListLimit(Long informerListLimit) { return this; } + public Builder withFieldSelector(FieldSelector fieldSelector) { + config.withFieldSelector(fieldSelector); + return this; + } + public void updateFrom(InformerConfiguration informerConfig) { if (informerConfig != null) { final var informerConfigName = informerConfig.getName(); @@ -281,7 +286,8 @@ public void updateFrom(InformerConfiguration informerConfig) { .withOnUpdateFilter(informerConfig.getOnUpdateFilter()) .withOnDeleteFilter(informerConfig.getOnDeleteFilter()) .withGenericFilter(informerConfig.getGenericFilter()) - .withInformerListLimit(informerConfig.getInformerListLimit()); + .withInformerListLimit(informerConfig.getInformerListLimit()) + .withFieldSelector(informerConfig.getFieldSelector()); } } 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 1e1607dd8b..f833edffe6 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 @@ -134,6 +134,18 @@ private InformerWrapper createEventSource( ResourceEventHandler eventHandler, String namespaceIdentifier) { final var informerConfig = configuration.getInformerConfig(); + + if (informerConfig.getFieldSelector() != null + && !informerConfig.getFieldSelector().getFields().isEmpty()) { + for (var f : informerConfig.getFieldSelector().getFields()) { + if (f.negated()) { + filteredBySelectorClient = filteredBySelectorClient.withoutField(f.path(), f.value()); + } else { + filteredBySelectorClient = filteredBySelectorClient.withField(f.path(), f.value()); + } + } + } + var informer = Optional.ofNullable(informerConfig.getInformerListLimit()) .map(filteredBySelectorClient::withLimit) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/fieldselector/FieldSelectorIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/fieldselector/FieldSelectorIT.java new file mode 100644 index 0000000000..5b32f15265 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/fieldselector/FieldSelectorIT.java @@ -0,0 +1,73 @@ +package io.javaoperatorsdk.operator.baseapi.fieldselector; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.SecretBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +import static io.javaoperatorsdk.operator.baseapi.fieldselector.FieldSelectorTestReconciler.MY_SECRET_TYPE; +import static io.javaoperatorsdk.operator.baseapi.fieldselector.FieldSelectorTestReconciler.OTHER_SECRET_TYPE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class FieldSelectorIT { + + public static final String TEST_1 = "test1"; + public static final String TEST_2 = "test2"; + public static final String TEST_3 = "test3"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new FieldSelectorTestReconciler()) + .build(); + + @Test + void filtersCustomResourceByLabel() { + + var customPrimarySecret = + extension.create( + new SecretBuilder() + .withMetadata(new ObjectMetaBuilder().withName(TEST_1).build()) + .withType(MY_SECRET_TYPE) + .build()); + + var otherSecret = + extension.create( + new SecretBuilder() + .withMetadata(new ObjectMetaBuilder().withName(TEST_2).build()) + .build()); + + var dependentSecret = + extension.create( + new SecretBuilder() + .withMetadata(new ObjectMetaBuilder().withName(TEST_3).build()) + .withType(OTHER_SECRET_TYPE) + .build()); + + await() + .pollDelay(Duration.ofMillis(150)) + .untilAsserted( + () -> { + var r = extension.getReconcilerOfType(FieldSelectorTestReconciler.class); + assertThat(r.getReconciledSecrets()).containsExactly(TEST_1); + + assertThat( + r.getDependentSecretEventSource() + .get(ResourceID.fromResource(dependentSecret))) + .isPresent(); + assertThat( + r.getDependentSecretEventSource() + .get(ResourceID.fromResource(customPrimarySecret))) + .isNotPresent(); + assertThat( + r.getDependentSecretEventSource().get(ResourceID.fromResource(otherSecret))) + .isNotPresent(); + }); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/fieldselector/FieldSelectorTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/fieldselector/FieldSelectorTestReconciler.java new file mode 100644 index 0000000000..1e3fddcf83 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/fieldselector/FieldSelectorTestReconciler.java @@ -0,0 +1,69 @@ +package io.javaoperatorsdk.operator.baseapi.fieldselector; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.Secret; +import io.javaoperatorsdk.operator.api.config.informer.Field; +import io.javaoperatorsdk.operator.api.config.informer.FieldSelectorBuilder; +import io.javaoperatorsdk.operator.api.config.informer.Informer; +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.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.processing.event.source.informer.InformerEventSource; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@ControllerConfiguration( + informer = + @Informer( + fieldSelector = + @Field(path = "type", value = FieldSelectorTestReconciler.MY_SECRET_TYPE))) +public class FieldSelectorTestReconciler implements Reconciler, TestExecutionInfoProvider { + + public static final String MY_SECRET_TYPE = "my-secret-type"; + public static final String OTHER_SECRET_TYPE = "my-dependent-secret-type"; + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + private Set reconciledSecrets = Collections.synchronizedSet(new HashSet<>()); + private InformerEventSource dependentSecretEventSource; + + @Override + public UpdateControl reconcile(Secret resource, Context context) { + reconciledSecrets.add(resource.getMetadata().getName()); + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } + + public Set getReconciledSecrets() { + return reconciledSecrets; + } + + @Override + public List> prepareEventSources(EventSourceContext context) { + dependentSecretEventSource = + new InformerEventSource<>( + InformerEventSourceConfiguration.from(Secret.class, Secret.class) + .withNamespacesInheritedFromController() + .withFieldSelector( + new FieldSelectorBuilder().withField("type", OTHER_SECRET_TYPE).build()) + .build(), + context); + + return List.of(dependentSecretEventSource); + } + + public InformerEventSource getDependentSecretEventSource() { + return dependentSecretEventSource; + } +} From d86737a01f4da962e562d90aa5320c98da54fd15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 23 Jun 2025 13:07:05 +0200 Subject: [PATCH 02/15] improve: remove owner refernce check (#2838) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit this was added to fabric8 client meanwhile Signed-off-by: Attila Mészáros --- .../operator/ReconcilerUtils.java | 33 --------------- .../KubernetesDependentResource.java | 2 - .../operator/ReconcilerUtilsTest.java | 40 ------------------- 3 files changed, 75 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtils.java index ea7c58acfb..2a87e045fd 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtils.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtils.java @@ -6,14 +6,11 @@ import java.lang.reflect.Method; import java.util.Arrays; import java.util.Locale; -import java.util.Objects; import java.util.function.Predicate; import java.util.regex.Pattern; import io.fabric8.kubernetes.api.builder.Builder; -import io.fabric8.kubernetes.api.model.GenericKubernetesResource; import io.fabric8.kubernetes.api.model.HasMetadata; -import io.fabric8.kubernetes.api.model.Namespaced; import io.fabric8.kubernetes.client.CustomResource; import io.fabric8.kubernetes.client.KubernetesClientException; import io.fabric8.kubernetes.client.utils.Serialization; @@ -73,36 +70,6 @@ public static String getNameFor(Class reconcilerClass) { return getDefaultNameFor(reconcilerClass); } - public static void checkIfCanAddOwnerReference(HasMetadata owner, HasMetadata resource) { - if (owner instanceof GenericKubernetesResource - || resource instanceof GenericKubernetesResource) { - return; - } - if (owner instanceof Namespaced) { - if (!(resource instanceof Namespaced)) { - throw new OperatorException( - "Cannot add owner reference from a cluster scoped to a namespace scoped resource." - + resourcesIdentifierDescription(owner, resource)); - } else if (!Objects.equals( - owner.getMetadata().getNamespace(), resource.getMetadata().getNamespace())) { - throw new OperatorException( - "Cannot add owner reference between two resource in different namespaces." - + resourcesIdentifierDescription(owner, resource)); - } - } - } - - private static String resourcesIdentifierDescription(HasMetadata owner, HasMetadata resource) { - return " Owner name: " - + owner.getMetadata().getName() - + " Kind: " - + owner.getKind() - + ", Resource name: " - + resource.getMetadata().getName() - + " Kind: " - + resource.getKind(); - } - public static String getNameFor(Reconciler reconciler) { return getNameFor(reconciler.getClass()); } 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 69d145866d..d3ee7a0b8b 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 @@ -11,7 +11,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.ReconcilerUtils; import io.javaoperatorsdk.operator.api.config.dependent.Configured; import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; import io.javaoperatorsdk.operator.api.reconciler.Context; @@ -208,7 +207,6 @@ protected Resource prepare(Context

context, R desired, P primary, String a protected void addReferenceHandlingMetadata(R desired, P primary) { if (addOwnerReference()) { - ReconcilerUtils.checkIfCanAddOwnerReference(primary, desired); desired.addOwnerReference(primary); } else if (useNonOwnerRefBasedSecondaryToPrimaryMapping()) { addSecondaryToPrimaryMapperAnnotations(desired, primary); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsTest.java index abc83b94ff..6445373c78 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsTest.java @@ -8,8 +8,6 @@ import io.fabric8.kubernetes.api.model.apps.Deployment; import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder; import io.fabric8.kubernetes.api.model.apps.DeploymentSpec; -import io.fabric8.kubernetes.api.model.rbac.ClusterRole; -import io.fabric8.kubernetes.api.model.rbac.ClusterRoleBuilder; import io.fabric8.kubernetes.client.CustomResource; import io.fabric8.kubernetes.client.KubernetesClientException; import io.fabric8.kubernetes.client.http.HttpRequest; @@ -154,44 +152,6 @@ void handleKubernetesExceptionShouldThrowMissingCRDExceptionWhenAppropriate() { HasMetadata.getFullResourceName(Tomcat.class))); } - @Test - void checksIfOwnerReferenceCanBeAdded() { - assertThrows( - OperatorException.class, - () -> - ReconcilerUtils.checkIfCanAddOwnerReference( - namespacedResource(), namespacedResourceFromOtherNamespace())); - - assertThrows( - OperatorException.class, - () -> - ReconcilerUtils.checkIfCanAddOwnerReference( - namespacedResource(), clusterScopedResource())); - - assertDoesNotThrow( - () -> { - ReconcilerUtils.checkIfCanAddOwnerReference( - clusterScopedResource(), clusterScopedResource()); - ReconcilerUtils.checkIfCanAddOwnerReference(namespacedResource(), namespacedResource()); - }); - } - - private ClusterRole clusterScopedResource() { - return new ClusterRoleBuilder().withMetadata(new ObjectMetaBuilder().build()).build(); - } - - private ConfigMap namespacedResource() { - return new ConfigMapBuilder() - .withMetadata(new ObjectMetaBuilder().withNamespace("testns1").build()) - .build(); - } - - private ConfigMap namespacedResourceFromOtherNamespace() { - return new ConfigMapBuilder() - .withMetadata(new ObjectMetaBuilder().withNamespace("testns2").build()) - .build(); - } - @Group("tomcatoperator.io") @Version("v1") @ShortNames("tc") From cfa0ff81a5215ecd6e7ae599842ddbce3a5725c5 Mon Sep 17 00:00:00 2001 From: Donnerbart Date: Thu, 28 Aug 2025 13:19:43 +0200 Subject: [PATCH 03/15] Add AggregatedMetrics to support multiple Metrics implementations (#2917) * feature: add AggregatedMetrics to support multiple Metrics implementations Signed-off-by: David Sondermann --- .../en/docs/documentation/observability.md | 39 +++- .../api/monitoring/AggregatedMetrics.java | 105 ++++++++++ .../api/monitoring/AggregatedMetricsTest.java | 180 ++++++++++++++++++ 3 files changed, 317 insertions(+), 7 deletions(-) create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetricsTest.java diff --git a/docs/content/en/docs/documentation/observability.md b/docs/content/en/docs/documentation/observability.md index 27a68086d5..312c31967e 100644 --- a/docs/content/en/docs/documentation/observability.md +++ b/docs/content/en/docs/documentation/observability.md @@ -51,7 +51,7 @@ Operator operator = new Operator(client, o -> o.withMetrics(metrics)); ### Micrometer implementation The micrometer implementation is typically created using one of the provided factory methods which, depending on which -is used, will return either a ready to use instance or a builder allowing users to customized how the implementation +is used, will return either a ready to use instance or a builder allowing users to customize how the implementation behaves, in particular when it comes to the granularity of collected metrics. It is, for example, possible to collect metrics on a per-resource basis via tags that are associated with meters. This is the default, historical behavior but this will change in a future version of JOSDK because this dramatically increases the cardinality of metrics, which @@ -62,14 +62,13 @@ instance via: ```java MeterRegistry registry; // initialize your registry implementation -Metrics metrics = new MicrometerMetrics(registry); +Metrics metrics = MicrometerMetrics.newMicrometerMetricsBuilder(registry).build(); ``` -Note, however, that this constructor is deprecated and we encourage you to use the factory methods instead, which either -return a fully pre-configured instance or a builder object that will allow you to configure more easily how the instance -will behave. You can, for example, configure whether or not the implementation should collect metrics on a per-resource -basis, whether or not associated meters should be removed when a resource is deleted and how the clean-up is performed. -See the relevant classes documentation for more details. +The class provides factory methods which either return a fully pre-configured instance or a builder object that will +allow you to configure more easily how the instance will behave. You can, for example, configure whether the +implementation should collect metrics on a per-resource basis, whether associated meters should be removed when a +resource is deleted and how the clean-up is performed. See the relevant classes documentation for more details. For example, the following will create a `MicrometerMetrics` instance configured to collect metrics on a per-resource basis, deleting the associated meters after 5 seconds when a resource is deleted, using up to 2 threads to do so. @@ -109,4 +108,30 @@ brackets (`[]`) won't be present when per-resource collection is disabled and ta omitted if the associated value is empty. Of note, when in the context of controllers' execution metrics, these tag names are prefixed with `resource.`. This prefix might be removed in a future version for greater consistency. +### Aggregated Metrics +The `AggregatedMetrics` class provides a way to combine multiple metrics providers into a single metrics instance using +the composite pattern. This is particularly useful when you want to simultaneously collect metrics data from different +monitoring systems or providers. + +You can create an `AggregatedMetrics` instance by providing a list of existing metrics implementations: + +```java +// create individual metrics instances +Metrics micrometerMetrics = MicrometerMetrics.withoutPerResourceMetrics(registry); +Metrics customMetrics = new MyCustomMetrics(); +Metrics loggingMetrics = new LoggingMetrics(); + +// combine them into a single aggregated instance +Metrics aggregatedMetrics = new AggregatedMetrics(List.of( + micrometerMetrics, + customMetrics, + loggingMetrics +)); + +// use the aggregated metrics with your operator +Operator operator = new Operator(client, o -> o.withMetrics(aggregatedMetrics)); +``` + +This approach allows you to easily combine different metrics collection strategies, such as sending metrics to both +Prometheus (via Micrometer) and a custom logging system simultaneously. 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 new file mode 100644 index 0000000000..45b08f4a3b --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetrics.java @@ -0,0 +1,105 @@ +package io.javaoperatorsdk.operator.api.monitoring; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; +import io.javaoperatorsdk.operator.processing.Controller; +import io.javaoperatorsdk.operator.processing.event.Event; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +/** + * An aggregated implementation of the {@link Metrics} interface that delegates method calls to a + * collection of {@link Metrics} instances using the composite pattern. + * + *

This class allows multiple metrics providers to be combined into a single metrics instance, + * enabling simultaneous collection of metrics data by different monitoring systems or providers. + * All method calls are delegated to each metrics instance in the list in the order they were + * provided to the constructor. + * + *

Important: The {@link #timeControllerExecution(ControllerExecution)} method + * is handled specially - it is only invoked on the first metrics instance in the list, since it's + * not an idempotent operation and can only be executed once. The controller execution cannot be + * repeated multiple times as it would produce side effects and potentially inconsistent results. + * + *

All other methods are called on every metrics instance in the list, preserving the order of + * execution as specified in the constructor. + * + * @see Metrics + */ +public final class AggregatedMetrics implements Metrics { + + private final List metricsList; + + /** + * Creates a new AggregatedMetrics instance that will delegate method calls to the provided list + * of metrics instances. + * + * @param metricsList the list of metrics instances to delegate to; must not be null and must + * contain at least one metrics instance + * @throws NullPointerException if metricsList is null + * @throws IllegalArgumentException if metricsList is empty + */ + public AggregatedMetrics(List metricsList) { + Objects.requireNonNull(metricsList, "metricsList must not be null"); + if (metricsList.isEmpty()) { + throw new IllegalArgumentException("metricsList must contain at least one Metrics instance"); + } + this.metricsList = List.copyOf(metricsList); + } + + @Override + public void controllerRegistered(Controller controller) { + metricsList.forEach(metrics -> metrics.controllerRegistered(controller)); + } + + @Override + public void receivedEvent(Event event, Map metadata) { + metricsList.forEach(metrics -> metrics.receivedEvent(event, metadata)); + } + + @Override + public void reconcileCustomResource( + HasMetadata resource, RetryInfo retryInfo, Map metadata) { + metricsList.forEach(metrics -> metrics.reconcileCustomResource(resource, retryInfo, metadata)); + } + + @Override + public void failedReconciliation( + HasMetadata resource, Exception exception, Map metadata) { + metricsList.forEach(metrics -> metrics.failedReconciliation(resource, exception, metadata)); + } + + @Override + public void reconciliationExecutionStarted(HasMetadata resource, Map metadata) { + metricsList.forEach(metrics -> metrics.reconciliationExecutionStarted(resource, metadata)); + } + + @Override + public void reconciliationExecutionFinished(HasMetadata resource, Map metadata) { + metricsList.forEach(metrics -> metrics.reconciliationExecutionFinished(resource, metadata)); + } + + @Override + public void cleanupDoneFor(ResourceID resourceID, Map metadata) { + metricsList.forEach(metrics -> metrics.cleanupDoneFor(resourceID, metadata)); + } + + @Override + public void finishedReconciliation(HasMetadata resource, Map metadata) { + metricsList.forEach(metrics -> metrics.finishedReconciliation(resource, metadata)); + } + + @Override + public T timeControllerExecution(ControllerExecution execution) throws Exception { + return metricsList.get(0).timeControllerExecution(execution); + } + + @Override + public > T monitorSizeOf(T map, String name) { + metricsList.forEach(metrics -> metrics.monitorSizeOf(map, name)); + return map; + } +} 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 new file mode 100644 index 0000000000..38781b94c4 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/monitoring/AggregatedMetricsTest.java @@ -0,0 +1,180 @@ +package io.javaoperatorsdk.operator.api.monitoring; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; +import io.javaoperatorsdk.operator.processing.Controller; +import io.javaoperatorsdk.operator.processing.event.Event; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class AggregatedMetricsTest { + + private final Metrics metrics1 = mock(); + private final Metrics metrics2 = mock(); + private final Metrics metrics3 = mock(); + private final Controller controller = mock(); + private final Event event = mock(); + private final HasMetadata resource = mock(); + private final RetryInfo retryInfo = mock(); + private final ResourceID resourceID = mock(); + private final Metrics.ControllerExecution controllerExecution = mock(); + + private final Map metadata = Map.of("kind", "TestResource"); + private final AggregatedMetrics aggregatedMetrics = + new AggregatedMetrics(List.of(metrics1, metrics2, metrics3)); + + @Test + void constructor_shouldThrowNullPointerExceptionWhenMetricsListIsNull() { + assertThatThrownBy(() -> new AggregatedMetrics(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("metricsList must not be null"); + } + + @Test + void constructor_shouldThrowIllegalArgumentExceptionWhenMetricsListIsEmpty() { + assertThatThrownBy(() -> new AggregatedMetrics(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("metricsList must contain at least one Metrics instance"); + } + + @Test + void controllerRegistered_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.controllerRegistered(controller); + + final var inOrder = inOrder(metrics1, metrics2, metrics3); + inOrder.verify(metrics1).controllerRegistered(controller); + inOrder.verify(metrics2).controllerRegistered(controller); + inOrder.verify(metrics3).controllerRegistered(controller); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } + + @Test + void receivedEvent_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.receivedEvent(event, metadata); + + final var inOrder = inOrder(metrics1, metrics2, metrics3); + inOrder.verify(metrics1).receivedEvent(event, metadata); + inOrder.verify(metrics2).receivedEvent(event, metadata); + inOrder.verify(metrics3).receivedEvent(event, metadata); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } + + @Test + void reconcileCustomResource_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.reconcileCustomResource(resource, retryInfo, metadata); + + final var inOrder = inOrder(metrics1, metrics2, metrics3); + inOrder.verify(metrics1).reconcileCustomResource(resource, retryInfo, metadata); + inOrder.verify(metrics2).reconcileCustomResource(resource, retryInfo, metadata); + inOrder.verify(metrics3).reconcileCustomResource(resource, retryInfo, metadata); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } + + @Test + void failedReconciliation_shouldDelegateToAllMetricsInOrder() { + final var exception = new RuntimeException("Test exception"); + + aggregatedMetrics.failedReconciliation(resource, 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); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } + + @Test + void reconciliationExecutionStarted_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.reconciliationExecutionStarted(resource, metadata); + + final var inOrder = inOrder(metrics1, metrics2, metrics3); + inOrder.verify(metrics1).reconciliationExecutionStarted(resource, metadata); + inOrder.verify(metrics2).reconciliationExecutionStarted(resource, metadata); + inOrder.verify(metrics3).reconciliationExecutionStarted(resource, metadata); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } + + @Test + void reconciliationExecutionFinished_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.reconciliationExecutionFinished(resource, 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); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } + + @Test + void cleanupDoneFor_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.cleanupDoneFor(resourceID, metadata); + + final var inOrder = inOrder(metrics1, metrics2, metrics3); + inOrder.verify(metrics1).cleanupDoneFor(resourceID, metadata); + inOrder.verify(metrics2).cleanupDoneFor(resourceID, metadata); + inOrder.verify(metrics3).cleanupDoneFor(resourceID, metadata); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } + + @Test + void finishedReconciliation_shouldDelegateToAllMetricsInOrder() { + aggregatedMetrics.finishedReconciliation(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); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } + + @Test + void timeControllerExecution_shouldOnlyDelegateToFirstMetrics() throws Exception { + final var expectedResult = "execution result"; + when(metrics1.timeControllerExecution(controllerExecution)).thenReturn(expectedResult); + + final var result = aggregatedMetrics.timeControllerExecution(controllerExecution); + + assertThat(result).isEqualTo(expectedResult); + verify(metrics1).timeControllerExecution(controllerExecution); + verify(metrics2, never()).timeControllerExecution(any()); + verify(metrics3, never()).timeControllerExecution(any()); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } + + @Test + void timeControllerExecution_shouldPropagateException() throws Exception { + final var expectedException = new RuntimeException("Controller execution failed"); + when(metrics1.timeControllerExecution(controllerExecution)).thenThrow(expectedException); + + assertThatThrownBy(() -> aggregatedMetrics.timeControllerExecution(controllerExecution)) + .isSameAs(expectedException); + + verify(metrics1).timeControllerExecution(controllerExecution); + verify(metrics2, never()).timeControllerExecution(any()); + verify(metrics3, never()).timeControllerExecution(any()); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } + + @Test + void monitorSizeOf_shouldDelegateToAllMetricsInOrderAndReturnOriginalMap() { + final var testMap = Map.of("key1", "value1"); + final var mapName = "testMap"; + + final var result = aggregatedMetrics.monitorSizeOf(testMap, mapName); + + assertThat(result).isSameAs(testMap); + verify(metrics1).monitorSizeOf(testMap, mapName); + verify(metrics2).monitorSizeOf(testMap, mapName); + verify(metrics3).monitorSizeOf(testMap, mapName); + verifyNoMoreInteractions(metrics1, metrics2, metrics3); + } +} From bca885541cca239f04f21e1fdf01f904ac5539ff Mon Sep 17 00:00:00 2001 From: Martin Stefanko Date: Fri, 29 Aug 2025 13:28:01 +0200 Subject: [PATCH 04/15] feat: allow overriding test infrastructure kube client separately (#2764) Co-authored-by: Chris Laprun --- .../junit/AbstractOperatorExtension.java | 28 +++- .../ClusterDeployedOperatorExtension.java | 24 +++- .../operator/junit/HasKubernetesClient.java | 15 ++ .../junit/LocallyRunOperatorExtension.java | 14 +- .../InfrastructureClientIT.java | 134 ++++++++++++++++++ ...nfrastructureClientTestCustomResource.java | 13 ++ .../InfrastructureClientTestReconciler.java | 37 +++++ .../rbac-test-role-binding.yaml | 12 ++ .../infrastructureclient/rbac-test-role.yaml | 11 ++ 9 files changed, 276 insertions(+), 12 deletions(-) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/infrastructureclient/InfrastructureClientIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/infrastructureclient/InfrastructureClientTestCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/infrastructureclient/InfrastructureClientTestReconciler.java create mode 100644 operator-framework/src/test/resources/io/javaoperatorsdk/operator/baseapi/infrastructureclient/rbac-test-role-binding.yaml create mode 100644 operator-framework/src/test/resources/io/javaoperatorsdk/operator/baseapi/infrastructureclient/rbac-test-role.yaml diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java index 794bc11d9a..0ebcef2d5c 100644 --- a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java +++ b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/AbstractOperatorExtension.java @@ -38,6 +38,7 @@ public abstract class AbstractOperatorExtension public static final int DEFAULT_NAMESPACE_DELETE_TIMEOUT = 90; private final KubernetesClient kubernetesClient; + private final KubernetesClient infrastructureKubernetesClient; protected final List infrastructure; protected Duration infrastructureTimeout; protected final boolean oneNamespacePerClass; @@ -56,10 +57,15 @@ protected AbstractOperatorExtension( boolean preserveNamespaceOnError, boolean waitForNamespaceDeletion, KubernetesClient kubernetesClient, + KubernetesClient infrastructureKubernetesClient, Function namespaceNameSupplier, Function perClassNamespaceNameSupplier) { + this.infrastructureKubernetesClient = + infrastructureKubernetesClient != null + ? infrastructureKubernetesClient + : new KubernetesClientBuilder().build(); this.kubernetesClient = - kubernetesClient != null ? kubernetesClient : new KubernetesClientBuilder().build(); + kubernetesClient != null ? kubernetesClient : this.infrastructureKubernetesClient; this.infrastructure = infrastructure; this.infrastructureTimeout = infrastructureTimeout; this.oneNamespacePerClass = oneNamespacePerClass; @@ -94,6 +100,11 @@ public KubernetesClient getKubernetesClient() { return kubernetesClient; } + @Override + public KubernetesClient getInfrastructureKubernetesClient() { + return infrastructureKubernetesClient; + } + public String getNamespace() { return namespace; } @@ -141,7 +152,7 @@ protected void beforeEachImpl(ExtensionContext context) { protected void before(ExtensionContext context) { LOGGER.info("Initializing integration test in namespace {}", namespace); - kubernetesClient + infrastructureKubernetesClient .namespaces() .resource( new NamespaceBuilder() @@ -149,8 +160,8 @@ protected void before(ExtensionContext context) { .build()) .serverSideApply(); - kubernetesClient.resourceList(infrastructure).serverSideApply(); - kubernetesClient + infrastructureKubernetesClient.resourceList(infrastructure).serverSideApply(); + infrastructureKubernetesClient .resourceList(infrastructure) .waitUntilReady(infrastructureTimeout.toMillis(), TimeUnit.MILLISECONDS); } @@ -172,16 +183,19 @@ protected void after(ExtensionContext context) { if (preserveNamespaceOnError && context.getExecutionException().isPresent()) { LOGGER.info("Preserving namespace {}", namespace); } else { - kubernetesClient.resourceList(infrastructure).delete(); + infrastructureKubernetesClient.resourceList(infrastructure).delete(); deleteOperator(); LOGGER.info("Deleting namespace {} and stopping operator", namespace); - kubernetesClient.namespaces().withName(namespace).delete(); + infrastructureKubernetesClient.namespaces().withName(namespace).delete(); if (waitForNamespaceDeletion) { LOGGER.info("Waiting for namespace {} to be deleted", namespace); Awaitility.await("namespace deleted") .pollInterval(50, TimeUnit.MILLISECONDS) .atMost(namespaceDeleteTimeout, TimeUnit.SECONDS) - .until(() -> kubernetesClient.namespaces().withName(namespace).get() == null); + .until( + () -> + infrastructureKubernetesClient.namespaces().withName(namespace).get() + == null); } } } diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java index 3fc49d4575..7273b464de 100644 --- a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java +++ b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/ClusterDeployedOperatorExtension.java @@ -39,6 +39,7 @@ private ClusterDeployedOperatorExtension( boolean waitForNamespaceDeletion, boolean oneNamespacePerClass, KubernetesClient kubernetesClient, + KubernetesClient infrastructureKubernetesClient, Function namespaceNameSupplier, Function perClassNamespaceNameSupplier) { super( @@ -48,6 +49,7 @@ private ClusterDeployedOperatorExtension( preserveNamespaceOnError, waitForNamespaceDeletion, kubernetesClient, + infrastructureKubernetesClient, namespaceNameSupplier, perClassNamespaceNameSupplier); this.operatorDeployment = operatorDeployment; @@ -69,7 +71,7 @@ protected void before(ExtensionContext context) { final var crdPath = "./target/classes/META-INF/fabric8/"; final var crdSuffix = "-v1.yml"; - final var kubernetesClient = getKubernetesClient(); + final var kubernetesClient = getInfrastructureKubernetesClient(); for (var crdFile : Objects.requireNonNull( new File(crdPath).listFiles((ignored, name) -> name.endsWith(crdSuffix)))) { @@ -107,13 +109,17 @@ protected void before(ExtensionContext context) { @Override protected void deleteOperator() { - getKubernetesClient().resourceList(operatorDeployment).inNamespace(namespace).delete(); + getInfrastructureKubernetesClient() + .resourceList(operatorDeployment) + .inNamespace(namespace) + .delete(); } public static class Builder extends AbstractBuilder { private final List operatorDeployment; private Duration deploymentTimeout; private KubernetesClient kubernetesClient; + private KubernetesClient infrastructureKubernetesClient; protected Builder() { super(); @@ -150,7 +156,18 @@ public Builder withKubernetesClient(KubernetesClient kubernetesClient) { return this; } + public Builder withInfrastructureKubernetesClient(KubernetesClient kubernetesClient) { + this.infrastructureKubernetesClient = kubernetesClient; + return this; + } + public ClusterDeployedOperatorExtension build() { + infrastructureKubernetesClient = + infrastructureKubernetesClient != null + ? infrastructureKubernetesClient + : new KubernetesClientBuilder().build(); + kubernetesClient = + kubernetesClient != null ? kubernetesClient : infrastructureKubernetesClient; return new ClusterDeployedOperatorExtension( operatorDeployment, deploymentTimeout, @@ -159,7 +176,8 @@ public ClusterDeployedOperatorExtension build() { preserveNamespaceOnError, waitForNamespaceDeletion, oneNamespacePerClass, - kubernetesClient != null ? kubernetesClient : new KubernetesClientBuilder().build(), + kubernetesClient, + infrastructureKubernetesClient, namespaceNameSupplier, perClassNamespaceNameSupplier); } diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/HasKubernetesClient.java b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/HasKubernetesClient.java index d93032333f..f3757cf0fc 100644 --- a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/HasKubernetesClient.java +++ b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/HasKubernetesClient.java @@ -3,5 +3,20 @@ import io.fabric8.kubernetes.client.KubernetesClient; public interface HasKubernetesClient { + /** + * Returns the main Kubernetes client that is used to deploy the operator to the cluster. + * + * @return the main Kubernetes client + */ KubernetesClient getKubernetesClient(); + + /** + * Returns the Kubernetes client that is used to deploy infrastructure resources to the cluster + * such as clusterroles, clusterrolebindings, etc. This client can be different from the main + * client in case you need to test the operator with a different restrictions more closely + * resembling the real restrictions it will have in production. + * + * @return the infrastructure Kubernetes client + */ + KubernetesClient getInfrastructureKubernetesClient(); } diff --git a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java index 54cb57544d..3d4f81de3c 100644 --- a/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java +++ b/operator-framework-junit5/src/main/java/io/javaoperatorsdk/operator/junit/LocallyRunOperatorExtension.java @@ -66,6 +66,7 @@ private LocallyRunOperatorExtension( boolean waitForNamespaceDeletion, boolean oneNamespacePerClass, KubernetesClient kubernetesClient, + KubernetesClient infrastructureKubernetesClient, Consumer configurationServiceOverrider, Function namespaceNameSupplier, Function perClassNamespaceNameSupplier, @@ -78,6 +79,7 @@ private LocallyRunOperatorExtension( preserveNamespaceOnError, waitForNamespaceDeletion, kubernetesClient, + infrastructureKubernetesClient, namespaceNameSupplier, perClassNamespaceNameSupplier); this.reconcilers = reconcilers; @@ -240,7 +242,7 @@ public Operator getOperator() { protected void before(ExtensionContext context) { super.before(context); - final var kubernetesClient = getKubernetesClient(); + final var kubernetesClient = getInfrastructureKubernetesClient(); for (var ref : portForwards) { String podName = @@ -313,7 +315,7 @@ protected void before(ExtensionContext context) { protected void after(ExtensionContext context) { super.after(context); - var kubernetesClient = getKubernetesClient(); + var kubernetesClient = getInfrastructureKubernetesClient(); var iterator = appliedCRDs.iterator(); while (iterator.hasNext()) { @@ -365,6 +367,7 @@ public static class Builder extends AbstractBuilder { private final List additionalCRDs = new ArrayList<>(); private Consumer beforeStartHook; private KubernetesClient kubernetesClient; + private KubernetesClient infrastructureKubernetesClient; protected Builder() { super(); @@ -419,6 +422,12 @@ public Builder withKubernetesClient(KubernetesClient kubernetesClient) { return this; } + public Builder withInfrastructureKubernetesClient( + KubernetesClient infrastructureKubernetesClient) { + this.infrastructureKubernetesClient = infrastructureKubernetesClient; + return this; + } + public Builder withAdditionalCustomResourceDefinition( Class customResource) { additionalCustomResourceDefinitions.add(customResource); @@ -452,6 +461,7 @@ public LocallyRunOperatorExtension build() { waitForNamespaceDeletion, oneNamespacePerClass, kubernetesClient, + infrastructureKubernetesClient, configurationServiceOverrider, namespaceNameSupplier, perClassNamespaceNameSupplier, 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 new file mode 100644 index 0000000000..7754b19eee --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/infrastructureclient/InfrastructureClientIT.java @@ -0,0 +1,134 @@ +package io.javaoperatorsdk.operator.baseapi.infrastructureclient; + +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.rbac.ClusterRole; +import io.fabric8.kubernetes.api.model.rbac.ClusterRoleBinding; +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.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; + +class InfrastructureClientIT { + + private static final String RBAC_TEST_ROLE = "rbac-test-role.yaml"; + private static final String RBAC_TEST_ROLE_BINDING = "rbac-test-role-binding.yaml"; + private static final String RBAC_TEST_USER = "rbac-test-user"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new InfrastructureClientTestReconciler()) + .withKubernetesClient( + new KubernetesClientBuilder() + .withConfig(new ConfigBuilder().withImpersonateUsername(RBAC_TEST_USER).build()) + .build()) + .withInfrastructureKubernetesClient( + new KubernetesClientBuilder().build()) // no limitations + .build(); + + /** + * We need to apply the cluster role also before the CRD deployment so the rbac-test-user is + * permitted to deploy it + */ + public InfrastructureClientIT() { + applyClusterRole(RBAC_TEST_ROLE); + applyClusterRoleBinding(RBAC_TEST_ROLE_BINDING); + } + + @BeforeEach + void setup() { + applyClusterRole(RBAC_TEST_ROLE); + applyClusterRoleBinding(RBAC_TEST_ROLE_BINDING); + } + + @AfterEach + void cleanup() { + removeClusterRoleBinding(RBAC_TEST_ROLE_BINDING); + removeClusterRole(RBAC_TEST_ROLE); + } + + @Test + void canCreateInfrastructure() { + var resource = new InfrastructureClientTestCustomResource(); + resource.setMetadata( + new ObjectMetaBuilder().withName("infrastructure-client-resource").build()); + operator.create(resource); + + await() + .atMost(5, TimeUnit.SECONDS) + .untilAsserted( + () -> { + InfrastructureClientTestCustomResource r = + operator.get( + InfrastructureClientTestCustomResource.class, + "infrastructure-client-resource"); + assertThat(r).isNotNull(); + }); + + assertThat( + operator + .getReconcilerOfType(InfrastructureClientTestReconciler.class) + .getNumberOfExecutions()) + .isEqualTo(1); + } + + @Test + void shouldNotAccessNotPermittedResources() { + assertThatThrownBy( + () -> + operator + .getKubernetesClient() + .apiextensions() + .v1() + .customResourceDefinitions() + .list()) + .isInstanceOf(KubernetesClientException.class) + .hasMessageContaining( + "User \"%s\" cannot list resource \"customresourcedefinitions\"" + .formatted(RBAC_TEST_USER)); + + // but we should be able to access all resources with the infrastructure client + var deploymentList = + operator + .getInfrastructureKubernetesClient() + .apiextensions() + .v1() + .customResourceDefinitions() + .list(); + assertThat(deploymentList).isNotNull(); + } + + private void applyClusterRoleBinding(String filename) { + var clusterRoleBinding = + ReconcilerUtils.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); + operator.getInfrastructureKubernetesClient().resource(clusterRole).serverSideApply(); + } + + private void removeClusterRoleBinding(String filename) { + var clusterRoleBinding = + ReconcilerUtils.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); + operator.getInfrastructureKubernetesClient().resource(clusterRole).delete(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/infrastructureclient/InfrastructureClientTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/infrastructureclient/InfrastructureClientTestCustomResource.java new file mode 100644 index 0000000000..65e0738daa --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/infrastructureclient/InfrastructureClientTestCustomResource.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.operator.baseapi.infrastructureclient; + +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("ict") +public class InfrastructureClientTestCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/infrastructureclient/InfrastructureClientTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/infrastructureclient/InfrastructureClientTestReconciler.java new file mode 100644 index 0000000000..a906fd7d0e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/infrastructureclient/InfrastructureClientTestReconciler.java @@ -0,0 +1,37 @@ +package io.javaoperatorsdk.operator.baseapi.infrastructureclient; + +import java.util.concurrent.atomic.AtomicInteger; + +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.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +@ControllerConfiguration(name = InfrastructureClientTestReconciler.TEST_RECONCILER) +public class InfrastructureClientTestReconciler + implements Reconciler, TestExecutionInfoProvider { + + private static final Logger log = + LoggerFactory.getLogger(InfrastructureClientTestReconciler.class); + + public static final String TEST_RECONCILER = "InfrastructureClientTestReconciler"; + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + InfrastructureClientTestCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + log.info("Reconciled for: {}", ResourceID.fromResource(resource)); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/resources/io/javaoperatorsdk/operator/baseapi/infrastructureclient/rbac-test-role-binding.yaml b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/baseapi/infrastructureclient/rbac-test-role-binding.yaml new file mode 100644 index 0000000000..64e577c4c1 --- /dev/null +++ b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/baseapi/infrastructureclient/rbac-test-role-binding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: rbac-test-role-binding +subjects: + - kind: User + name: rbac-test-user + apiGroup: rbac.authorization.k8s.io +roleRef: + kind: ClusterRole + name: rbac-test-role + apiGroup: rbac.authorization.k8s.io \ No newline at end of file diff --git a/operator-framework/src/test/resources/io/javaoperatorsdk/operator/baseapi/infrastructureclient/rbac-test-role.yaml b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/baseapi/infrastructureclient/rbac-test-role.yaml new file mode 100644 index 0000000000..9c1d790d94 --- /dev/null +++ b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/baseapi/infrastructureclient/rbac-test-role.yaml @@ -0,0 +1,11 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: rbac-test-role +rules: + - apiGroups: [ "apiextensions.k8s.io"] + resources: [ "customresourcedefinitions" ] + verbs: [ "create", "update", "patch", "delete", "deletecollection" ] # explicitly don't include "list" for the test + - apiGroups: [ "sample.javaoperatorsdk" ] + resources: [ "infrastructureclienttestcustomresources" ] + verbs: [ "get", "list", "watch", "create", "update", "patch", "delete", "deletecollection" ] From 7f48dcd97404846eeec304c47517fc5204c7f5c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 2 Sep 2025 16:06:08 +0200 Subject: [PATCH 05/15] improve: duration for initial interval in GenericRetry (#2929) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/processing/retry/GenericRetry.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetry.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetry.java index a8e1c5b466..9b42971e7c 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetry.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetry.java @@ -1,5 +1,7 @@ package io.javaoperatorsdk.operator.processing.retry; +import java.time.Duration; + import io.javaoperatorsdk.operator.api.config.AnnotationConfigurable; public class GenericRetry implements Retry, AnnotationConfigurable { @@ -40,6 +42,11 @@ public long getInitialInterval() { return initialInterval; } + public GenericRetry setInitialInterval(Duration initialInterval) { + setInitialInterval(initialInterval.toMillis()); + return this; + } + public GenericRetry setInitialInterval(long initialInterval) { this.initialInterval = initialInterval; return this; From e4a81152b6eaf695eb0495b5cc0acfb929d53c99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 8 Sep 2025 15:17:25 +0200 Subject: [PATCH 06/15] improve: GenericRetry does not provide mutable singleton instance (#2934) --- .../operator/api/config/ControllerConfiguration.java | 2 +- .../operator/processing/retry/GenericRetry.java | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) 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 2c18fa55d3..3898493c82 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 @@ -57,7 +57,7 @@ default boolean isGenerationAware() { String getAssociatedReconcilerClassName(); default Retry getRetry() { - return GenericRetry.DEFAULT; + return GenericRetry.defaultLimitedExponentialRetry(); } @SuppressWarnings("rawtypes") diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetry.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetry.java index 9b42971e7c..0ff44a6fe7 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetry.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetry.java @@ -10,10 +10,15 @@ public class GenericRetry implements Retry, AnnotationConfigurable private double intervalMultiplier = GradualRetry.DEFAULT_MULTIPLIER; private long maxInterval = GradualRetry.DEFAULT_MAX_INTERVAL; + /** + * @deprecated use {@link GenericRetry#defaultLimitedExponentialRetry()} instead this instance. + * Since GenericRetry is mutable, singleton is problematic. + */ + @Deprecated(forRemoval = true) public static final Retry DEFAULT = new GenericRetry(); public static GenericRetry defaultLimitedExponentialRetry() { - return (GenericRetry) DEFAULT; + return new GenericRetry(); } public static GenericRetry noRetry() { From d4330feebbc28fef2cd5b052e7c4ae77aaee7ce1 Mon Sep 17 00:00:00 2001 From: Chris Laprun Date: Tue, 9 Sep 2025 15:15:58 +0200 Subject: [PATCH 07/15] chore(deps): update to Fabric8 client 7.4.0 (#2937) Signed-off-by: Chris Laprun --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 93a0e2d711..d515193fdf 100644 --- a/pom.xml +++ b/pom.xml @@ -53,7 +53,7 @@ https://sonarcloud.io jdk 5.13.4 - 7.3.1 + 7.4.0 2.0.17 2.25.2 5.20.0 From 4b533c7a05811c5140e0660ebdd55d67525a110f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 16 Sep 2025 15:14:06 +0200 Subject: [PATCH 08/15] feat: add experimental annotation (#2853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/api/reconciler/Experimental.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Experimental.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Experimental.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Experimental.java new file mode 100644 index 0000000000..ae219fbd1d --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Experimental.java @@ -0,0 +1,24 @@ +package io.javaoperatorsdk.operator.api.reconciler; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks experimental features. + * + *

Experimental features are not yet stable and may change in future releases. Usually based on + * the feedback of the users. + */ +@Retention(RetentionPolicy.SOURCE) +@Target({ElementType.METHOD, ElementType.TYPE, ElementType.FIELD, ElementType.PACKAGE}) +public @interface Experimental { + + /** + * Describes why the annotated element is experimental. + * + * @return the experimental description. + */ + String value(); +} From 77149f1cde22cdb497ba23629c947aa8c60c4891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sat, 13 Sep 2025 17:09:41 +0200 Subject: [PATCH 09/15] feat: expectation pattern support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../processing/expectation/Expectation.java | 32 ++++++++++ .../expectation/ExpectationManager.java | 59 +++++++++++++++++++ .../expectation/ExpectationResult.java | 15 +++++ .../expectation/ExpectationStatus.java | 7 +++ .../expectation/RegisteredExpectation.java | 14 +++++ 5 files changed, 127 insertions(+) create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/Expectation.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatus.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/RegisteredExpectation.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/Expectation.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/Expectation.java new file mode 100644 index 0000000000..c9a026cd53 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/Expectation.java @@ -0,0 +1,32 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +import java.util.function.BiPredicate; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +public interface Expectation

{ + + String UNNAMED = "unnamed"; + + boolean isFulfilled(P primary, Context

context); + + default String name() { + return UNNAMED; + } + + static

Expectation

createExpectation( + String name, BiPredicate> predicate) { + return new Expectation<>() { + @Override + public String name() { + return name; + } + + @Override + public boolean isFulfilled(P primary, Context

context) { + return predicate.test(primary, context); + } + }; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java new file mode 100644 index 0000000000..1e97573694 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java @@ -0,0 +1,59 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +public class ExpectationManager

{ + + private final ConcurrentHashMap> registeredExpectations = + new ConcurrentHashMap<>(); + + public void setExpectation(P primary, Expectation

expectation, Duration timeout) { + registeredExpectations.put( + ResourceID.fromResource(primary), + new RegisteredExpectation<>(LocalDateTime.now(), timeout, expectation)); + } + + /** + * Checks if provided expectation is fulfilled. Return the expectation result. If the result of + * expectation is fulfilled or timeout, the expectation is automatically removed; + */ + public Optional> checkOnExpectation(P primary, Context

context) { + var resourceID = ResourceID.fromResource(primary); + var regExp = registeredExpectations.get(ResourceID.fromResource(primary)); + if (regExp == null) { + return Optional.empty(); + } + if (regExp.expectation().isFulfilled(primary, context)) { + registeredExpectations.remove(resourceID); + return Optional.of( + new ExpectationResult<>(regExp.expectation(), ExpectationStatus.FULFILLED)); + } else if (regExp.isTimedOut()) { + registeredExpectations.remove(resourceID); + return Optional.of( + new ExpectationResult<>(regExp.expectation(), ExpectationStatus.TIMED_OUT)); + } else { + return Optional.of( + new ExpectationResult<>(regExp.expectation(), ExpectationStatus.NOT_FULFILLED)); + } + } + + public boolean isExpectationPresent(P primary) { + return registeredExpectations.containsKey(ResourceID.fromResource(primary)); + } + + public Optional> getExpectation(P primary) { + var regExp = registeredExpectations.get(ResourceID.fromResource(primary)); + return Optional.ofNullable(regExp).map(RegisteredExpectation::expectation); + } + + public void cleanup(P primary) { + registeredExpectations.remove(ResourceID.fromResource(primary)); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java new file mode 100644 index 0000000000..4c6535bb95 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +import io.fabric8.kubernetes.api.model.HasMetadata; + +public record ExpectationResult

( + Expectation

expectation, ExpectationStatus status) { + + public boolean isFulfilled() { + return status == ExpectationStatus.FULFILLED; + } + + public boolean isTimedOut() { + return status == ExpectationStatus.TIMED_OUT; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatus.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatus.java new file mode 100644 index 0000000000..55ee791b9d --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatus.java @@ -0,0 +1,7 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +public enum ExpectationStatus { + FULFILLED, + NOT_FULFILLED, + TIMED_OUT +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/RegisteredExpectation.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/RegisteredExpectation.java new file mode 100644 index 0000000000..fe24f6dd25 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/RegisteredExpectation.java @@ -0,0 +1,14 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +import java.time.Duration; +import java.time.LocalDateTime; + +import io.fabric8.kubernetes.api.model.HasMetadata; + +record RegisteredExpectation

( + LocalDateTime registeredAt, Duration timeout, Expectation

expectation) { + + public boolean isTimedOut() { + return LocalDateTime.now().isAfter(registeredAt.plus(timeout)); + } +} From b496832b7c593d0b15a20dad286f3d0215376132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 15 Sep 2025 21:19:30 +0200 Subject: [PATCH 10/15] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/processing/expectation/ExpectationManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java index 1e97573694..f7b995a904 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java @@ -22,7 +22,7 @@ public void setExpectation(P primary, Expectation

expectation, Duration timeo /** * Checks if provided expectation is fulfilled. Return the expectation result. If the result of - * expectation is fulfilled or timeout, the expectation is automatically removed; + * expectation is fulfilled or timed out, the expectation is automatically removed; */ public Optional> checkOnExpectation(P primary, Context

context) { var resourceID = ResourceID.fromResource(primary); From 63b690982a0b0d0d97bc207759cd72c0fff006c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 18 Sep 2025 10:04:52 +0200 Subject: [PATCH 11/15] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../expectation/ExpectationManager.java | 6 ++- .../expectation/ExpectationResult.java | 4 ++ .../PeriodicCleanerExpectationManager.java | 43 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java index f7b995a904..f9f39d64d3 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java @@ -11,7 +11,7 @@ public class ExpectationManager

{ - private final ConcurrentHashMap> registeredExpectations = + protected final ConcurrentHashMap> registeredExpectations = new ConcurrentHashMap<>(); public void setExpectation(P primary, Expectation

expectation, Duration timeout) { @@ -53,6 +53,10 @@ public Optional> getExpectation(P primary) { return Optional.ofNullable(regExp).map(RegisteredExpectation::expectation); } + public Optional getExpectationName(P primary) { + return getExpectation(primary).map(Expectation::name); + } + public void cleanup(P primary) { registeredExpectations.remove(ResourceID.fromResource(primary)); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java index 4c6535bb95..408050421a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java @@ -12,4 +12,8 @@ public boolean isFulfilled() { public boolean isTimedOut() { return status == ExpectationStatus.TIMED_OUT; } + + public String name() { + return expectation.name(); + } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java new file mode 100644 index 0000000000..f33740d43c --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java @@ -0,0 +1,43 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +import java.time.Duration; +import java.time.LocalDateTime; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.IndexedResourceCache; + +public class PeriodicCleanerExpectationManager

+ extends ExpectationManager

{ + + private final Duration cleanupDelayAfterExpiration; + private final IndexedResourceCache

primaryCache; + + // todo fixes schedule + public PeriodicCleanerExpectationManager(Duration period, Duration cleanupDelayAfterExpiration) { + this.cleanupDelayAfterExpiration = cleanupDelayAfterExpiration; + this.primaryCache = null; + } + + public PeriodicCleanerExpectationManager(Duration period, IndexedResourceCache

primaryCache) { + this.cleanupDelayAfterExpiration = null; + this.primaryCache = primaryCache; + } + + public void clean() { + registeredExpectations + .entrySet() + .removeIf( + e -> { + if (cleanupDelayAfterExpiration != null) { + return LocalDateTime.now() + .isAfter( + e.getValue() + .registeredAt() + .plus(e.getValue().timeout()) + .plus(cleanupDelayAfterExpiration)); + } else { + return primaryCache.get(e.getKey()).isEmpty(); + } + }); + } +} From c871242e23a9437712d9a4ea9ebbd1a5428fb278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 18 Sep 2025 11:37:52 +0200 Subject: [PATCH 12/15] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../PeriodicCleanerExpectationManager.java | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java index f33740d43c..5478141e22 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java @@ -2,6 +2,9 @@ import java.time.Duration; import java.time.LocalDateTime; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.reconciler.IndexedResourceCache; @@ -9,18 +12,32 @@ public class PeriodicCleanerExpectationManager

extends ExpectationManager

{ + private final ScheduledExecutorService scheduler = + Executors.newScheduledThreadPool( + 1, + r -> { + Thread thread = Executors.defaultThreadFactory().newThread(r); + thread.setDaemon(true); + return thread; + }); + private final Duration cleanupDelayAfterExpiration; private final IndexedResourceCache

primaryCache; - // todo fixes schedule public PeriodicCleanerExpectationManager(Duration period, Duration cleanupDelayAfterExpiration) { - this.cleanupDelayAfterExpiration = cleanupDelayAfterExpiration; - this.primaryCache = null; + this(period, cleanupDelayAfterExpiration, null); } public PeriodicCleanerExpectationManager(Duration period, IndexedResourceCache

primaryCache) { - this.cleanupDelayAfterExpiration = null; + this(period, null, primaryCache); + } + + private PeriodicCleanerExpectationManager( + Duration period, Duration cleanupDelayAfterExpiration, IndexedResourceCache

primaryCache) { + this.cleanupDelayAfterExpiration = cleanupDelayAfterExpiration; this.primaryCache = primaryCache; + scheduler.scheduleWithFixedDelay( + this::clean, period.toMillis(), period.toMillis(), TimeUnit.MICROSECONDS); } public void clean() { @@ -40,4 +57,8 @@ public void clean() { } }); } + + void stop() { + scheduler.shutdownNow(); + } } From 191f89a26e77ca23692475b31c0a0659118a3988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 18 Sep 2025 15:57:55 +0200 Subject: [PATCH 13/15] unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../expectation/ExpectationManagerTest.java | 158 ++++++++++++++++++ .../expectation/ExpectationStatusTest.java | 35 ++++ .../expectation/ExpectationTest.java | 58 +++++++ ...PeriodicCleanerExpectationManagerTest.java | 149 +++++++++++++++++ 4 files changed, 400 insertions(+) create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManagerTest.java create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatusTest.java create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationTest.java create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManagerTest.java diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManagerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManagerTest.java new file mode 100644 index 0000000000..399ea2652f --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManagerTest.java @@ -0,0 +1,158 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +import java.time.Duration; +import java.util.Optional; + +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.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ExpectationManagerTest { + + private ExpectationManager expectationManager; + private ConfigMap configMap; + private Context context; + + @BeforeEach + void setUp() { + expectationManager = new ExpectationManager<>(); + configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder().withName("test-configmap").withNamespace("test-namespace").build()); + context = mock(Context.class); + } + + @Test + void setExpectationShouldStoreExpectation() { + Expectation expectation = mock(Expectation.class); + Duration timeout = Duration.ofMinutes(5); + + expectationManager.setExpectation(configMap, expectation, timeout); + + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + assertThat(expectationManager.getExpectation(configMap)).contains(expectation); + } + + @Test + void checkOnExpectationShouldReturnEmptyWhenNoExpectation() { + Optional> result = + expectationManager.checkOnExpectation(configMap, context); + + assertThat(result).isEmpty(); + } + + @Test + void checkOnExpectationShouldReturnFulfilledWhenExpectationMet() { + Expectation expectation = mock(Expectation.class); + when(expectation.isFulfilled(configMap, context)).thenReturn(true); + + expectationManager.setExpectation(configMap, expectation, Duration.ofMinutes(5)); + Optional> result = + expectationManager.checkOnExpectation(configMap, context); + + assertThat(result).isPresent(); + assertThat(result.get().status()).isEqualTo(ExpectationStatus.FULFILLED); + assertThat(result.get().expectation()).isEqualTo(expectation); + assertThat(expectationManager.isExpectationPresent(configMap)).isFalse(); + } + + @Test + void checkOnExpectationShouldReturnNotFulfilledWhenExpectationNotMet() { + Expectation expectation = mock(Expectation.class); + when(expectation.isFulfilled(configMap, context)).thenReturn(false); + + expectationManager.setExpectation(configMap, expectation, Duration.ofMinutes(5)); + Optional> result = + expectationManager.checkOnExpectation(configMap, context); + + assertThat(result).isPresent(); + assertThat(result.get().status()).isEqualTo(ExpectationStatus.NOT_FULFILLED); + assertThat(result.get().expectation()).isEqualTo(expectation); + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + } + + @Test + void checkOnExpectationShouldReturnTimedOutWhenExpectationExpired() throws InterruptedException { + Expectation expectation = mock(Expectation.class); + when(expectation.isFulfilled(configMap, context)).thenReturn(false); + + expectationManager.setExpectation(configMap, expectation, Duration.ofMillis(1)); + Thread.sleep(10); + Optional> result = + expectationManager.checkOnExpectation(configMap, context); + + assertThat(result).isPresent(); + assertThat(result.get().status()).isEqualTo(ExpectationStatus.TIMED_OUT); + assertThat(result.get().expectation()).isEqualTo(expectation); + assertThat(expectationManager.isExpectationPresent(configMap)).isFalse(); + } + + @Test + void getExpectationNameShouldReturnExpectationName() { + String expectedName = "test-expectation"; + Expectation expectation = mock(Expectation.class); + when(expectation.name()).thenReturn(expectedName); + + expectationManager.setExpectation(configMap, expectation, Duration.ofMinutes(5)); + Optional name = expectationManager.getExpectationName(configMap); + + assertThat(name).contains(expectedName); + } + + @Test + void getExpectationNameShouldReturnEmptyWhenNoExpectation() { + Optional name = expectationManager.getExpectationName(configMap); + + assertThat(name).isEmpty(); + } + + @Test + void cleanupShouldRemoveExpectation() { + Expectation expectation = mock(Expectation.class); + + expectationManager.setExpectation(configMap, expectation, Duration.ofMinutes(5)); + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + + expectationManager.cleanup(configMap); + assertThat(expectationManager.isExpectationPresent(configMap)).isFalse(); + } + + @Test + void shouldHandleMultipleExpectationsForDifferentResources() { + ConfigMap configMap2 = new ConfigMap(); + configMap2.setMetadata( + new ObjectMetaBuilder() + .withName("test-configmap-2") + .withNamespace("test-namespace") + .build()); + + Expectation expectation1 = mock(Expectation.class); + Expectation expectation2 = mock(Expectation.class); + + expectationManager.setExpectation(configMap, expectation1, Duration.ofMinutes(5)); + expectationManager.setExpectation(configMap2, expectation2, Duration.ofMinutes(5)); + + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + assertThat(expectationManager.isExpectationPresent(configMap2)).isTrue(); + assertThat(expectationManager.getExpectation(configMap)).contains(expectation1); + assertThat(expectationManager.getExpectation(configMap2)).contains(expectation2); + } + + @Test + void setExpectationShouldReplaceExistingExpectation() { + Expectation expectation1 = mock(Expectation.class); + Expectation expectation2 = mock(Expectation.class); + + expectationManager.setExpectation(configMap, expectation1, Duration.ofMinutes(5)); + expectationManager.setExpectation(configMap, expectation2, Duration.ofMinutes(5)); + + assertThat(expectationManager.getExpectation(configMap)).contains(expectation2); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatusTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatusTest.java new file mode 100644 index 0000000000..feba8cb651 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatusTest.java @@ -0,0 +1,35 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ExpectationStatusTest { + + @Test + void shouldHaveThreeStatuses() { + ExpectationStatus[] values = ExpectationStatus.values(); + + assertThat(values).hasSize(3); + assertThat(values) + .containsExactlyInAnyOrder( + ExpectationStatus.FULFILLED, + ExpectationStatus.NOT_FULFILLED, + ExpectationStatus.TIMED_OUT); + } + + @Test + void shouldHaveCorrectNames() { + assertThat(ExpectationStatus.FULFILLED.name()).isEqualTo("FULFILLED"); + assertThat(ExpectationStatus.NOT_FULFILLED.name()).isEqualTo("NOT_FULFILLED"); + assertThat(ExpectationStatus.TIMED_OUT.name()).isEqualTo("TIMED_OUT"); + } + + @Test + void shouldSupportValueOf() { + assertThat(ExpectationStatus.valueOf("FULFILLED")).isEqualTo(ExpectationStatus.FULFILLED); + assertThat(ExpectationStatus.valueOf("NOT_FULFILLED")) + .isEqualTo(ExpectationStatus.NOT_FULFILLED); + assertThat(ExpectationStatus.valueOf("TIMED_OUT")).isEqualTo(ExpectationStatus.TIMED_OUT); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationTest.java new file mode 100644 index 0000000000..7e94994bc3 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationTest.java @@ -0,0 +1,58 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class ExpectationTest { + + @Test + void createExpectationWithCustomName() { + String customName = "test-expectation"; + Expectation expectation = + Expectation.createExpectation(customName, (primary, context) -> true); + + assertThat(expectation.name()).isEqualTo(customName); + } + + @Test + void createExpectationWithPredicate() { + ConfigMap configMap = new ConfigMap(); + Context context = mock(Context.class); + + Expectation trueExpectation = + Expectation.createExpectation("always-true", (primary, ctx) -> true); + Expectation falseExpectation = + Expectation.createExpectation("always-false", (primary, ctx) -> false); + + assertThat(trueExpectation.isFulfilled(configMap, context)).isTrue(); + assertThat(falseExpectation.isFulfilled(configMap, context)).isFalse(); + } + + @Test + void expectationShouldWorkWithGenericTypes() { + ConfigMap configMap = new ConfigMap(); + Context context = mock(Context.class); + + Expectation expectation = + new Expectation<>() { + @Override + public String name() { + return "custom-expectation"; + } + + @Override + public boolean isFulfilled(ConfigMap primary, Context context) { + return primary != null; + } + }; + + assertThat(expectation.name()).isEqualTo("custom-expectation"); + assertThat(expectation.isFulfilled(configMap, context)).isTrue(); + assertThat(expectation.isFulfilled(null, context)).isFalse(); + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManagerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManagerTest.java new file mode 100644 index 0000000000..0bba070955 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManagerTest.java @@ -0,0 +1,149 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.reconciler.IndexedResourceCache; +import io.javaoperatorsdk.operator.processing.event.ResourceID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.Mockito.when; + +class PeriodicCleanerExpectationManagerTest { + + @Mock private IndexedResourceCache primaryCache; + + private PeriodicCleanerExpectationManager expectationManager; + private ConfigMap configMap; + private AutoCloseable closeable; + + @BeforeEach + void setUp() { + closeable = MockitoAnnotations.openMocks(this); + configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder().withName("test-configmap").withNamespace("test-namespace").build()); + } + + @AfterEach + void tearDown() throws Exception { + if (expectationManager != null) { + expectationManager.stop(); + } + closeable.close(); + } + + @Test + void shouldCleanExpiredExpectationsWithCleanupDelay() { + Duration period = Duration.ofMillis(50); + Duration cleanupDelay = Duration.ofMillis(10); + expectationManager = new PeriodicCleanerExpectationManager<>(period, cleanupDelay); + + Expectation expectation = (primary, context) -> false; + expectationManager.setExpectation(configMap, expectation, Duration.ofMillis(1)); + + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + + await() + .atMost(200, TimeUnit.MILLISECONDS) + .untilAsserted( + () -> assertThat(expectationManager.isExpectationPresent(configMap)).isFalse()); + } + + @Test + void shouldCleanExpectationsWhenResourceNotInCache() { + Duration period = Duration.ofMillis(50); + expectationManager = new PeriodicCleanerExpectationManager<>(period, primaryCache); + + ResourceID resourceId = ResourceID.fromResource(configMap); + when(primaryCache.get(resourceId)).thenReturn(java.util.Optional.empty()); + + Expectation expectation = (primary, context) -> false; + expectationManager.setExpectation(configMap, expectation, Duration.ofMinutes(10)); + + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + + await() + .atMost(200, TimeUnit.MILLISECONDS) + .untilAsserted( + () -> assertThat(expectationManager.isExpectationPresent(configMap)).isFalse()); + } + + @Test + void shouldNotCleanExpectationsWhenResourceInCache() throws InterruptedException { + Duration period = Duration.ofMillis(50); + expectationManager = new PeriodicCleanerExpectationManager<>(period, primaryCache); + + ResourceID resourceId = ResourceID.fromResource(configMap); + when(primaryCache.get(resourceId)).thenReturn(java.util.Optional.of(configMap)); + + Expectation expectation = (primary, context) -> false; + expectationManager.setExpectation(configMap, expectation, Duration.ofMinutes(10)); + + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + + Thread.sleep(150); + + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + } + + @Test + void shouldNotCleanNonExpiredExpectationsWithCleanupDelay() throws InterruptedException { + Duration period = Duration.ofMillis(50); + Duration cleanupDelay = Duration.ofMinutes(1); + expectationManager = new PeriodicCleanerExpectationManager<>(period, cleanupDelay); + + Expectation expectation = (primary, context) -> false; + expectationManager.setExpectation(configMap, expectation, Duration.ofMillis(1)); + + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + + Thread.sleep(150); + + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + } + + @Test + void stopShouldShutdownScheduler() { + Duration period = Duration.ofMillis(50); + expectationManager = new PeriodicCleanerExpectationManager<>(period, Duration.ofMillis(10)); + + expectationManager.stop(); + + Expectation expectation = (primary, context) -> false; + expectationManager.setExpectation(configMap, expectation, Duration.ofMillis(1)); + + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + } + + @Test + void cleanShouldWorkDirectly() { + Duration period = Duration.ofMinutes(10); + Duration cleanupDelay = Duration.ofMillis(1); + expectationManager = new PeriodicCleanerExpectationManager<>(period, cleanupDelay); + + Expectation expectation = (primary, context) -> false; + expectationManager.setExpectation(configMap, expectation, Duration.ofMillis(1)); + + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + assertThat(expectationManager.isExpectationPresent(configMap)).isTrue(); + + expectationManager.clean(); + + assertThat(expectationManager.isExpectationPresent(configMap)).isFalse(); + } +} From 4da5d2bd4a82a5948f594a5dc2221dcea30a14b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 18 Sep 2025 15:58:21 +0200 Subject: [PATCH 14/15] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../expectation/ExpectationStatusTest.java | 35 ----------- .../expectation/ExpectationTest.java | 58 ------------------- 2 files changed, 93 deletions(-) delete mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatusTest.java delete mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationTest.java diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatusTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatusTest.java deleted file mode 100644 index feba8cb651..0000000000 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationStatusTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package io.javaoperatorsdk.operator.processing.expectation; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class ExpectationStatusTest { - - @Test - void shouldHaveThreeStatuses() { - ExpectationStatus[] values = ExpectationStatus.values(); - - assertThat(values).hasSize(3); - assertThat(values) - .containsExactlyInAnyOrder( - ExpectationStatus.FULFILLED, - ExpectationStatus.NOT_FULFILLED, - ExpectationStatus.TIMED_OUT); - } - - @Test - void shouldHaveCorrectNames() { - assertThat(ExpectationStatus.FULFILLED.name()).isEqualTo("FULFILLED"); - assertThat(ExpectationStatus.NOT_FULFILLED.name()).isEqualTo("NOT_FULFILLED"); - assertThat(ExpectationStatus.TIMED_OUT.name()).isEqualTo("TIMED_OUT"); - } - - @Test - void shouldSupportValueOf() { - assertThat(ExpectationStatus.valueOf("FULFILLED")).isEqualTo(ExpectationStatus.FULFILLED); - assertThat(ExpectationStatus.valueOf("NOT_FULFILLED")) - .isEqualTo(ExpectationStatus.NOT_FULFILLED); - assertThat(ExpectationStatus.valueOf("TIMED_OUT")).isEqualTo(ExpectationStatus.TIMED_OUT); - } -} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationTest.java deleted file mode 100644 index 7e94994bc3..0000000000 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationTest.java +++ /dev/null @@ -1,58 +0,0 @@ -package io.javaoperatorsdk.operator.processing.expectation; - -import org.junit.jupiter.api.Test; - -import io.fabric8.kubernetes.api.model.ConfigMap; -import io.javaoperatorsdk.operator.api.reconciler.Context; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; - -class ExpectationTest { - - @Test - void createExpectationWithCustomName() { - String customName = "test-expectation"; - Expectation expectation = - Expectation.createExpectation(customName, (primary, context) -> true); - - assertThat(expectation.name()).isEqualTo(customName); - } - - @Test - void createExpectationWithPredicate() { - ConfigMap configMap = new ConfigMap(); - Context context = mock(Context.class); - - Expectation trueExpectation = - Expectation.createExpectation("always-true", (primary, ctx) -> true); - Expectation falseExpectation = - Expectation.createExpectation("always-false", (primary, ctx) -> false); - - assertThat(trueExpectation.isFulfilled(configMap, context)).isTrue(); - assertThat(falseExpectation.isFulfilled(configMap, context)).isFalse(); - } - - @Test - void expectationShouldWorkWithGenericTypes() { - ConfigMap configMap = new ConfigMap(); - Context context = mock(Context.class); - - Expectation expectation = - new Expectation<>() { - @Override - public String name() { - return "custom-expectation"; - } - - @Override - public boolean isFulfilled(ConfigMap primary, Context context) { - return primary != null; - } - }; - - assertThat(expectation.name()).isEqualTo("custom-expectation"); - assertThat(expectation.isFulfilled(configMap, context)).isTrue(); - assertThat(expectation.isFulfilled(null, context)).isFalse(); - } -} From ca4c8a2998c72870c0e3e47714ce1a8fa9310989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 30 Sep 2025 10:01:03 +0200 Subject: [PATCH 15/15] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../ExpectationCustomResource.java | 12 ++++++++ .../baseapi/expectation/ExpectationIT.java | 16 +++++++++++ .../expectation/ExpectationReconciler.java | 28 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationCustomResource.java new file mode 100644 index 0000000000..ea4b676653 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationCustomResource.java @@ -0,0 +1,12 @@ +package io.javaoperatorsdk.operator.baseapi.expectation; + +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("ecr") +public class ExpectationCustomResource extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationIT.java new file mode 100644 index 0000000000..c88fdbe2cc --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationIT.java @@ -0,0 +1,16 @@ +package io.javaoperatorsdk.operator.baseapi.expectation; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +class ExpectationIT { + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder().withReconciler(new ExpectationReconciler()).build(); + + @Test + void testExpectation() {} +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java new file mode 100644 index 0000000000..9e797ad2be --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/expectation/ExpectationReconciler.java @@ -0,0 +1,28 @@ +package io.javaoperatorsdk.operator.baseapi.expectation; + +import java.util.List; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +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.processing.expectation.ExpectationManager; + +public class ExpectationReconciler implements Reconciler { + + ExpectationManager expectationManager = new ExpectationManager<>(); + + @Override + public UpdateControl reconcile( + ExpectationCustomResource resource, Context context) { + + return UpdateControl.noUpdate(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + return List.of(); + } +}