diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java index d390a5ad67..2df74d4298 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java @@ -35,12 +35,83 @@ default Optional getSecondaryResource(Class expectedType) { return getSecondaryResource(expectedType, null); } - Set getSecondaryResources(Class expectedType); + /** + * Retrieves a {@link Set} of the secondary resources of the specified type, which are associated + * with the primary resource being processed, possibly making sure that only the latest version of + * each resource is retrieved. + * + *

Note: While this method returns a {@link Set}, it is possible to get several copies of a + * given resource albeit all with different {@code resourceVersion}. If you want to avoid this + * situation, call {@link #getSecondaryResources(Class, boolean)} with the {@code deduplicate} + * parameter set to {@code true}. + * + * @param expectedType a class representing the type of secondary resources to retrieve + * @param the type of secondary resources to retrieve + * @return a {@link Stream} of secondary resources of the specified type, possibly deduplicated + */ + default Set getSecondaryResources(Class expectedType) { + return getSecondaryResources(expectedType, false); + } + + /** + * Retrieves a {@link Set} of the secondary resources of the specified type, which are associated + * with the primary resource being processed, possibly making sure that only the latest version of + * each resource is retrieved. + * + *

Note: While this method returns a {@link Set}, it is possible to get several copies of a + * given resource albeit all with different {@code resourceVersion}. If you want to avoid this + * situation, ask for the deduplicated version by setting the {@code deduplicate} parameter to + * {@code true}. + * + * @param expectedType a class representing the type of secondary resources to retrieve + * @param deduplicate {@code true} if only the latest version of each resource should be kept, + * {@code false} otherwise + * @param the type of secondary resources to retrieve + * @return a {@link Set} of secondary resources of the specified type, possibly deduplicated + * @throws IllegalArgumentException if the secondary resource type cannot be deduplicated because + * it's not extending {@link HasMetadata}, which is required to access the resource version + * @since 5.3.0 + */ + Set getSecondaryResources(Class expectedType, boolean deduplicate); + /** + * Retrieves a {@link Stream} of the secondary resources of the specified type, which are + * associated with the primary resource being processed, possibly making sure that only the latest + * version of each resource is retrieved. + * + *

Note: It is possible to get several copies of a given resource albeit all with different + * {@code resourceVersion}. If you want to avoid this situation, call {@link + * #getSecondaryResourcesAsStream(Class, boolean)} with the {@code deduplicate} parameter set to + * {@code true}. + * + * @param expectedType a class representing the type of secondary resources to retrieve + * @param the type of secondary resources to retrieve + * @return a {@link Stream} of secondary resources of the specified type, possibly deduplicated + */ default Stream getSecondaryResourcesAsStream(Class expectedType) { - return getSecondaryResources(expectedType).stream(); + return getSecondaryResourcesAsStream(expectedType, false); } + /** + * Retrieves a {@link Stream} of the secondary resources of the specified type, which are + * associated with the primary resource being processed, possibly making sure that only the latest + * version of each resource is retrieved. + * + *

Note: It is possible to get several copies of a given resource albeit all with different + * {@code resourceVersion}. If you want to avoid this situation, ask for the deduplicated version + * by setting the {@code deduplicate} parameter to {@code true}. + * + * @param expectedType a class representing the type of secondary resources to retrieve + * @param deduplicate {@code true} if only the latest version of each resource should be kept, + * {@code false} otherwise + * @param the type of secondary resources to retrieve + * @return a {@link Stream} of secondary resources of the specified type, possibly deduplicated + * @throws IllegalArgumentException if the secondary resource type cannot be deduplicated because + * it's not extending {@link HasMetadata}, which is required to access the resource version + * @since 5.3.0 + */ + Stream getSecondaryResourcesAsStream(Class expectedType, boolean deduplicate); + Optional getSecondaryResource(Class expectedType, String eventSourceName); ControllerConfiguration

getControllerConfiguration(); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java index 3c7d6319a6..ac5a7b41b9 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java @@ -15,6 +15,7 @@ */ package io.javaoperatorsdk.operator.api.reconciler; +import java.util.HashSet; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -26,6 +27,7 @@ import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.DefaultManagedWorkflowAndDependentResourceContext; @@ -36,7 +38,6 @@ import io.javaoperatorsdk.operator.processing.event.ResourceID; public class DefaultContext

implements Context

{ - private RetryInfo retryInfo; private final Controller

controller; private final P primaryResource; @@ -71,15 +72,44 @@ public Optional getRetryInfo() { } @Override - public Set getSecondaryResources(Class expectedType) { + public Set getSecondaryResources(Class expectedType, boolean deduplicate) { + if (deduplicate) { + final var deduplicatedMap = deduplicatedMap(getSecondaryResourcesAsStream(expectedType)); + return new HashSet<>(deduplicatedMap.values()); + } return getSecondaryResourcesAsStream(expectedType).collect(Collectors.toSet()); } - @Override - public Stream getSecondaryResourcesAsStream(Class expectedType) { - return controller.getEventSourceManager().getEventSourcesFor(expectedType).stream() - .map(es -> es.getSecondaryResources(primaryResource)) - .flatMap(Set::stream); + public Stream getSecondaryResourcesAsStream(Class expectedType, boolean deduplicate) { + final var stream = + controller.getEventSourceManager().getEventSourcesFor(expectedType).stream() + .mapMulti( + (es, consumer) -> es.getSecondaryResources(primaryResource).forEach(consumer)); + if (deduplicate) { + if (!HasMetadata.class.isAssignableFrom(expectedType)) { + throw new IllegalArgumentException("Can only de-duplicate HasMetadata descendants"); + } + return deduplicatedMap(stream).values().stream(); + } else { + return stream; + } + } + + private Map deduplicatedMap(Stream stream) { + return stream.collect( + Collectors.toUnmodifiableMap( + DefaultContext::resourceID, + Function.identity(), + (existing, replacement) -> + compareResourceVersions(existing, replacement) >= 0 ? existing : replacement)); + } + + private static ResourceID resourceID(Object hasMetadata) { + return ResourceID.fromResource((HasMetadata) hasMetadata); + } + + private static int compareResourceVersions(Object v1, Object v2) { + return ReconcilerUtilsInternal.compareResourceVersions((HasMetadata) v1, (HasMetadata) v2); } @Override diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 9c42e6adfb..de4d00d717 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -535,13 +535,13 @@ public R resourcePatch(R resource, UnaryOperator upda if (esList.isEmpty()) { throw new IllegalStateException("No event source found for type: " + resource.getClass()); } + var es = esList.get(0); if (esList.size() > 1) { - throw new IllegalStateException( - "Multiple event sources found for: " - + resource.getClass() - + " please provide the target event source"); + log.warn( + "Multiple event sources found for type: {}, selecting first with name {}", + resource.getClass(), + es.name()); } - var es = esList.get(0); if (es instanceof ManagedInformerEventSource mes) { return resourcePatch(resource, updateOperation, (ManagedInformerEventSource) mes); } else { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceID.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceID.java index 9db8c7539f..ea460bb5cb 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceID.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ResourceID.java @@ -63,9 +63,26 @@ public boolean equals(Object o) { } public boolean isSameResource(HasMetadata hasMetadata) { + if (hasMetadata == null) { + return false; + } final var metadata = hasMetadata.getMetadata(); - return getName().equals(metadata.getName()) - && getNamespace().map(ns -> ns.equals(metadata.getNamespace())).orElse(true); + return isSameResource(metadata.getName(), metadata.getNamespace()); + } + + /** + * Whether this ResourceID points to the same resource as the one identified by the specified name + * and namespace. Note that this doesn't take API version or Kind into account so this should only + * be used when checking resources that are reasonably expected to be of the same type. + * + * @param name the name of the resource we want to check + * @param namespace the possibly {@code null} namespace of the resource we want to check + * @return {@code true} if this resource points to the same resource as the one pointed to by the + * specified name and namespace, {@code false} otherwise + * @since 5.3.0 + */ + public boolean isSameResource(String name, String namespace) { + return Objects.equals(this.name, name) && Objects.equals(this.namespace, namespace); } @Override diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index 6743ff436a..b778747417 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -218,7 +218,8 @@ public Set getSecondaryResources(P primary) { } return secondaryIDs.stream() .map(this::get) - .flatMap(Optional::stream) + .filter(Optional::isPresent) + .map(Optional::get) .collect(Collectors.toSet()); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContextTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContextTest.java index 064c73c7f9..4df8df385b 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContextTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContextTest.java @@ -15,13 +15,23 @@ */ package io.javaoperatorsdk.operator.api.reconciler; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Pod; +import io.fabric8.kubernetes.api.model.PodBuilder; import io.fabric8.kubernetes.api.model.Secret; import io.javaoperatorsdk.operator.processing.Controller; import io.javaoperatorsdk.operator.processing.event.EventSourceManager; import io.javaoperatorsdk.operator.processing.event.NoEventSourceForClassException; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -30,17 +40,21 @@ class DefaultContextTest { - private final Secret primary = new Secret(); - private final Controller mockController = mock(); + private DefaultContext context; + private Controller mockController; + private EventSourceManager mockManager; - private final DefaultContext context = - new DefaultContext<>(null, mockController, primary, false, false); + @BeforeEach + void setUp() { + mockController = mock(); + mockManager = mock(); + when(mockController.getEventSourceManager()).thenReturn(mockManager); + + context = new DefaultContext<>(null, mockController, new Secret(), false, false); + } @Test - @SuppressWarnings("unchecked") void getSecondaryResourceReturnsEmptyOptionalOnNonActivatedDRType() { - var mockManager = mock(EventSourceManager.class); - when(mockController.getEventSourceManager()).thenReturn(mockManager); when(mockController.workflowContainsDependentForType(ConfigMap.class)).thenReturn(true); when(mockManager.getEventSourceFor(any(), any())) .thenThrow(new NoEventSourceForClassException(ConfigMap.class)); @@ -56,4 +70,101 @@ void setRetryInfo() { assertThat(newContext).isSameAs(context); assertThat(newContext.getRetryInfo()).hasValue(retryInfo); } + + @Test + void latestDistinctKeepsOnlyLatestResourceVersion() { + // Create multiple resources with same name and namespace but different versions + var pod1v1 = podWithNameAndVersion("pod1", "100"); + var pod1v2 = podWithNameAndVersion("pod1", "200"); + var pod1v3 = podWithNameAndVersion("pod1", "150"); + + // Create a resource with different name + var pod2v1 = podWithNameAndVersion("pod2", "100"); + + // Create a resource with same name but different namespace + var pod1OtherNsv1 = podWithNameAndVersion("pod1", "50", "other"); + + setUpEventSourceWith(pod1v1, pod1v2, pod1v3, pod1OtherNsv1, pod2v1); + + var result = context.getSecondaryResourcesAsStream(Pod.class, true).toList(); + + // Should have 3 resources: pod1 in default (latest version 200), pod2 in default, and pod1 in + // other + assertThat(result).hasSize(3); + + // Find pod1 in default namespace - should have version 200 + final var pod1InDefault = + result.stream() + .filter(r -> ResourceID.fromResource(r).isSameResource("pod1", "default")) + .findFirst() + .orElseThrow(); + assertThat(pod1InDefault.getMetadata().getResourceVersion()).isEqualTo("200"); + + // Find pod2 in default namespace - should exist + HasMetadata pod2InDefault = + result.stream() + .filter(r -> ResourceID.fromResource(r).isSameResource("pod2", "default")) + .findFirst() + .orElseThrow(); + assertThat(pod2InDefault.getMetadata().getResourceVersion()).isEqualTo("100"); + + // Find pod1 in other namespace - should exist + HasMetadata pod1InOther = + result.stream() + .filter(r -> ResourceID.fromResource(r).isSameResource("pod1", "other")) + .findFirst() + .orElseThrow(); + assertThat(pod1InOther.getMetadata().getResourceVersion()).isEqualTo("50"); + } + + private void setUpEventSourceWith(Pod... pods) { + EventSource mockEventSource = mock(); + when(mockEventSource.getSecondaryResources(any())).thenReturn(Set.of(pods)); + when(mockManager.getEventSourcesFor(Pod.class)).thenReturn(List.of(mockEventSource)); + } + + private static Pod podWithNameAndVersion( + String name, String resourceVersion, String... namespace) { + final var ns = namespace != null && namespace.length > 0 ? namespace[0] : "default"; + return new PodBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(name) + .withNamespace(ns) + .withResourceVersion(resourceVersion) + .build()) + .build(); + } + + @Test + void latestDistinctHandlesEmptyStream() { + var result = context.getSecondaryResourcesAsStream(Pod.class, true).toList(); + + assertThat(result).isEmpty(); + } + + @Test + void latestDistinctHandlesSingleResource() { + final var pod = podWithNameAndVersion("pod1", "100"); + setUpEventSourceWith(pod); + + var result = context.getSecondaryResourcesAsStream(Pod.class, true).toList(); + + assertThat(result).hasSize(1); + assertThat(result).contains(pod); + } + + @Test + void latestDistinctComparesNumericVersionsCorrectly() { + // Test that version 1000 is greater than version 999 (not lexicographic) + final var podV999 = podWithNameAndVersion("pod1", "999"); + final var podV1000 = podWithNameAndVersion("pod1", "1000"); + setUpEventSourceWith(podV999, podV1000); + + var result = context.getSecondaryResourcesAsStream(Pod.class, true).toList(); + + assertThat(result).hasSize(1); + HasMetadata resultPod = result.iterator().next(); + assertThat(resultPod.getMetadata().getResourceVersion()).isEqualTo("1000"); + } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java index 82ecf8996c..8d0176cd4a 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java @@ -38,27 +38,27 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; +@SuppressWarnings("unchecked") class ResourceOperationsTest { private static final String FINALIZER_NAME = "test.javaoperatorsdk.io/finalizer"; private Context context; - private KubernetesClient client; - private MixedOperation mixedOperation; + + @SuppressWarnings("rawtypes") private Resource resourceOp; + private ControllerEventSource controllerEventSource; - private ControllerConfiguration controllerConfiguration; private ResourceOperations resourceOperations; @BeforeEach - @SuppressWarnings("unchecked") void setupMocks() { context = mock(Context.class); - client = mock(KubernetesClient.class); - mixedOperation = mock(MixedOperation.class); + final var client = mock(KubernetesClient.class); + final var mixedOperation = mock(MixedOperation.class); resourceOp = mock(Resource.class); controllerEventSource = mock(ControllerEventSource.class); - controllerConfiguration = mock(ControllerConfiguration.class); + final var controllerConfiguration = mock(ControllerConfiguration.class); var eventSourceRetriever = mock(EventSourceRetriever.class); @@ -290,7 +290,7 @@ void resourcePatchThrowsWhenNoEventSourceFound() { } @Test - void resourcePatchThrowsWhenMultipleEventSourcesFound() { + void resourcePatchUsesFirstEventSourceIfMultipleEventSourcesPresent() { var resource = TestUtils.testCustomResource1(); var eventSourceRetriever = mock(EventSourceRetriever.class); var eventSource1 = mock(ManagedInformerEventSource.class); @@ -300,13 +300,10 @@ void resourcePatchThrowsWhenMultipleEventSourcesFound() { when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) .thenReturn(List.of(eventSource1, eventSource2)); - var exception = - assertThrows( - IllegalStateException.class, - () -> resourceOperations.resourcePatch(resource, UnaryOperator.identity())); + resourceOperations.resourcePatch(resource, UnaryOperator.identity()); - assertThat(exception.getMessage()).contains("Multiple event sources found for"); - assertThat(exception.getMessage()).contains("please provide the target event source"); + verify(eventSource1, times(1)) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); } @Test diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctIT.java new file mode 100644 index 0000000000..c66b0dc4de --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctIT.java @@ -0,0 +1,125 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.latestdistinct; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.annotation.Sample; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.latestdistinct.LatestDistinctTestReconciler.LABEL_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@Sample( + tldr = "Latest Distinct with Multiple InformerEventSources", + description = + """ + Demonstrates using two separate InformerEventSource instances for ConfigMaps with \ + overlapping watches, combined with latestDistinctList() to deduplicate resources by \ + keeping the latest version. Also tests ReconcileUtils methods for patching resources \ + with proper cache updates. + """) +class LatestDistinctIT { + + public static final String TEST_RESOURCE_NAME = "test-resource"; + public static final String CONFIG_MAP_1 = "config-map-1"; + public static final String DEFAULT_VALUE = "defaultValue"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(LatestDistinctTestReconciler.class) + .build(); + + @Test + void testLatestDistinctListWithTwoInformerEventSources() { + // Create the custom resource + var resource = createTestCustomResource(); + resource = extension.create(resource); + + // Create ConfigMaps with type1 label (watched by first event source) + var cm1 = createConfigMap(CONFIG_MAP_1, resource); + extension.create(cm1); + + // Wait for reconciliation + var reconciler = extension.getReconcilerOfType(LatestDistinctTestReconciler.class); + await() + .atMost(Duration.ofSeconds(5)) + .pollDelay(Duration.ofMillis(300)) + .untilAsserted( + () -> { + var updatedResource = + extension.get(LatestDistinctTestResource.class, TEST_RESOURCE_NAME); + assertThat(updatedResource.getStatus()).isNotNull(); + // Should see 3 distinct ConfigMaps + assertThat(updatedResource.getStatus().getConfigMapCount()).isEqualTo(1); + assertThat(reconciler.isErrorOccurred()).isFalse(); + // note that since there are two event source, and we do the update through one event + // source + // the other will still propagate an event + assertThat(reconciler.getNumberOfExecutions()).isEqualTo(2); + }); + } + + private LatestDistinctTestResource createTestCustomResource() { + var resource = new LatestDistinctTestResource(); + resource.setMetadata( + new ObjectMetaBuilder() + .withName(TEST_RESOURCE_NAME) + .withNamespace(extension.getNamespace()) + .build()); + resource.setSpec(new LatestDistinctTestResourceSpec()); + return resource; + } + + private ConfigMap createConfigMap(String name, LatestDistinctTestResource owner) { + Map labels = new HashMap<>(); + labels.put(LABEL_KEY, "val"); + + Map data = new HashMap<>(); + data.put("key", DEFAULT_VALUE); + + return new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(name) + .withNamespace(extension.getNamespace()) + .withLabels(labels) + .build()) + .withData(data) + .withNewMetadata() + .withName(name) + .withNamespace(extension.getNamespace()) + .withLabels(labels) + .addNewOwnerReference() + .withApiVersion(owner.getApiVersion()) + .withKind(owner.getKind()) + .withName(owner.getMetadata().getName()) + .withUid(owner.getMetadata().getUid()) + .endOwnerReference() + .endMetadata() + .build(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestReconciler.java new file mode 100644 index 0000000000..77745ddaba --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestReconciler.java @@ -0,0 +1,140 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.latestdistinct; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.ErrorStatusUpdateControl; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +@ControllerConfiguration +public class LatestDistinctTestReconciler implements Reconciler { + + public static final String EVENT_SOURCE_1_NAME = "configmap-es-1"; + public static final String EVENT_SOURCE_2_NAME = "configmap-es-2"; + public static final String LABEL_KEY = "configmap-type"; + public static final String KEY_2 = "key2"; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + private volatile boolean errorOccurred = false; + + @Override + public UpdateControl reconcile( + LatestDistinctTestResource resource, Context context) { + + // Update status with information from ConfigMaps + if (resource.getStatus() == null) { + resource.setStatus(new LatestDistinctTestResourceStatus()); + } + var allConfigMaps = context.getSecondaryResourcesAsStream(ConfigMap.class).toList(); + if (allConfigMaps.size() < 2) { + // wait until both informers see the config map + return UpdateControl.noUpdate(); + } + // makes sure that distinc config maps returned + var distinctConfigMaps = context.getSecondaryResourcesAsStream(ConfigMap.class, true).toList(); + if (distinctConfigMaps.size() != 1) { + errorOccurred = true; + throw new IllegalStateException(); + } + + resource.getStatus().setConfigMapCount(distinctConfigMaps.size()); + var configMap = distinctConfigMaps.get(0); + configMap.setData(Map.of(KEY_2, "val2")); + var updated = context.resourceOperations().update(configMap); + + // makes sure that distinct config maps returned + distinctConfigMaps = context.getSecondaryResourcesAsStream(ConfigMap.class, true).toList(); + if (distinctConfigMaps.size() != 1) { + errorOccurred = true; + throw new IllegalStateException(); + } + configMap = distinctConfigMaps.get(0); + if (!configMap.getData().containsKey(KEY_2) + || !configMap + .getMetadata() + .getResourceVersion() + .equals(updated.getMetadata().getResourceVersion())) { + errorOccurred = true; + throw new IllegalStateException(); + } + numberOfExecutions.incrementAndGet(); + return UpdateControl.patchStatus(resource); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + var configEs1 = + InformerEventSourceConfiguration.from(ConfigMap.class, LatestDistinctTestResource.class) + .withName(EVENT_SOURCE_1_NAME) + .withLabelSelector(LABEL_KEY) + .withNamespacesInheritedFromController() + .withSecondaryToPrimaryMapper( + cm -> + Set.of( + new ResourceID( + cm.getMetadata().getOwnerReferences().get(0).getName(), + cm.getMetadata().getNamespace()))) + .build(); + + var configEs2 = + InformerEventSourceConfiguration.from(ConfigMap.class, LatestDistinctTestResource.class) + .withName(EVENT_SOURCE_2_NAME) + .withLabelSelector(LABEL_KEY) + .withNamespacesInheritedFromController() + .withSecondaryToPrimaryMapper( + cm -> + Set.of( + new ResourceID( + cm.getMetadata().getOwnerReferences().get(0).getName(), + cm.getMetadata().getNamespace()))) + .build(); + + return List.of( + new InformerEventSource<>(configEs1, context), + new InformerEventSource<>(configEs2, context)); + } + + @Override + public ErrorStatusUpdateControl updateErrorStatus( + LatestDistinctTestResource resource, + Context context, + Exception e) { + errorOccurred = true; + return ErrorStatusUpdateControl.noStatusUpdate(); + } + + public boolean isErrorOccurred() { + return errorOccurred; + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResource.java new file mode 100644 index 0000000000..546e349b0a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResource.java @@ -0,0 +1,40 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.latestdistinct; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("ldt") +public class LatestDistinctTestResource + extends CustomResource + implements Namespaced { + + @Override + protected LatestDistinctTestResourceSpec initSpec() { + return new LatestDistinctTestResourceSpec(); + } + + @Override + protected LatestDistinctTestResourceStatus initStatus() { + return new LatestDistinctTestResourceStatus(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResourceSpec.java new file mode 100644 index 0000000000..acfefab85e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResourceSpec.java @@ -0,0 +1,28 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.latestdistinct; + +public class LatestDistinctTestResourceSpec { + private String value; + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResourceStatus.java new file mode 100644 index 0000000000..fd5ff82df5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/latestdistinct/LatestDistinctTestResourceStatus.java @@ -0,0 +1,28 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.latestdistinct; + +public class LatestDistinctTestResourceStatus { + private int configMapCount; + + public int getConfigMapCount() { + return configMapCount; + } + + public void setConfigMapCount(int configMapCount) { + this.configMapCount = configMapCount; + } +}