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/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/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/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(); +} 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/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/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..f9f39d64d3 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationManager.java @@ -0,0 +1,63 @@ +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

{ + + protected 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 timed out, 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 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 new file mode 100644 index 0000000000..408050421a --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/ExpectationResult.java @@ -0,0 +1,19 @@ +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; + } + + public String name() { + return expectation.name(); + } +} 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/PeriodicCleanerExpectationManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java new file mode 100644 index 0000000000..5478141e22 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/expectation/PeriodicCleanerExpectationManager.java @@ -0,0 +1,64 @@ +package io.javaoperatorsdk.operator.processing.expectation; + +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; + +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; + + public PeriodicCleanerExpectationManager(Duration period, Duration cleanupDelayAfterExpiration) { + this(period, cleanupDelayAfterExpiration, null); + } + + public PeriodicCleanerExpectationManager(Duration period, IndexedResourceCache

primaryCache) { + 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() { + 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(); + } + }); + } + + void stop() { + scheduler.shutdownNow(); + } +} 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)); + } +} 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..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 @@ -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 { @@ -8,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() { @@ -40,6 +47,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; 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-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/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(); + } +} 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/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(); + } +} 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/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" ] 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