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/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/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/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/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 ebd6089aa7..16b9a52744 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; @@ -206,8 +205,8 @@ 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); + var ref = desired.addOwnerReference(primary); + ref.setController(true); } else if (useNonOwnerRefBasedSecondaryToPrimaryMapping()) { addSecondaryToPrimaryMapperAnnotations(desired, primary); } 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-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") 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); + } +} 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; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/GenericKubernetesDependentTestBase.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/GenericKubernetesDependentTestBase.java index 061c0218f0..1ea9714a17 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/GenericKubernetesDependentTestBase.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/generickubernetesresource/GenericKubernetesDependentTestBase.java @@ -31,6 +31,7 @@ void testReconciliation() { assertThat(cm).isNotNull(); assertThat(cm.getData()) .containsEntry(ConfigMapGenericKubernetesDependent.KEY, INITIAL_DATA); + assertThat(cm.getMetadata().getOwnerReferences().get(0).getController()).isTrue(); }); resource.getSpec().setValue(CHANGED_DATA);