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 2df74d4298..98440fd6f3 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 @@ -25,6 +25,7 @@ import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ManagedWorkflowAndDependentResourceContext; import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever; +import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.IndexerResourceCache; public interface Context

{ @@ -114,6 +115,88 @@ default Stream getSecondaryResourcesAsStream(Class expectedType) { Optional getSecondaryResource(Class expectedType, String eventSourceName); + /** + * Retrieves a specific secondary resource by its {@link ResourceID} from the event source + * identified by the given name. + * + *

This is a typed convenience over manually retrieving the {@link + * io.javaoperatorsdk.operator.processing.event.source.EventSource} and calling its cache. When + * the underlying event source implements {@link + * io.javaoperatorsdk.operator.processing.event.source.Cache}, the lookup is a direct cache lookup + * and read-cache-after-write consistent. + * + *

{@code eventSourceName} may be {@code null}. When {@code null} and {@code expectedType} is + * part of a managed workflow whose activation condition may not have registered the event source, + * an empty {@link Optional} is returned instead of throwing {@link + * io.javaoperatorsdk.operator.processing.event.NoEventSourceForClassException}. + * + * @param expectedType the class representing the type of secondary resource to retrieve + * @param eventSourceName the name of the event source to look in (may be {@code null}) + * @param resourceID the {@link ResourceID} identifying the secondary resource + * @param the type of secondary resource to retrieve + * @return an {@link Optional} containing the matching secondary resource, or {@link + * Optional#empty()} if none matches + * @throws io.javaoperatorsdk.operator.processing.event.NoEventSourceForClassException if no event + * source is registered for the given type and name (and no workflow activation condition + * accounts for it) + * @since 5.3.5 + */ + Optional getSecondaryResource( + Class expectedType, String eventSourceName, ResourceID resourceID); + + /** + * Convenience overload of {@link #getSecondaryResource(Class, String, ResourceID)} that + * constructs the {@link ResourceID} using the given name and the primary resource's namespace. + * + *

If the primary resource is cluster-scoped (no namespace), the lookup is performed against + * the cluster scope. To target a specific namespace from a cluster-scoped primary, use {@link + * #getSecondaryResource(Class, String, ResourceID)} directly. + * + *

{@code eventSourceName} may be {@code null} with the same semantics as in {@link + * #getSecondaryResource(Class, String, ResourceID)}. + * + * @param expectedType the class representing the type of secondary resource to retrieve + * @param eventSourceName the name of the event source to look in (may be {@code null}) + * @param name the name of the secondary resource (namespace inferred from the primary) + * @param the type of secondary resource to retrieve + * @return an {@link Optional} containing the matching secondary resource, or {@link + * Optional#empty()} if none matches + * @since 5.3.5 + */ + default Optional getSecondaryResource( + Class expectedType, String eventSourceName, String name) { + return getSecondaryResource( + expectedType, + eventSourceName, + new ResourceID(name, getPrimaryResource().getMetadata().getNamespace())); + } + + /** + * Retrieves a {@link Stream} of the secondary resources of the specified type from the event + * source identified by the given name. Useful when several event sources are registered for the + * same type and you need to scope retrieval to one of them, or when you want to apply a custom + * filter at the call site. + * + *

When the underlying event source implements {@link ResourceCache}, the stream is + * read-cache-after-write consistent. + * + *

{@code eventSourceName} may be {@code null} with the same semantics as in {@link + * #getSecondaryResource(Class, String, ResourceID)}: when {@code null} and {@code expectedType} + * is part of a managed workflow whose activation condition may not have registered the event + * source, an empty {@link Stream} is returned instead of throwing {@link + * io.javaoperatorsdk.operator.processing.event.NoEventSourceForClassException}. + * + * @param expectedType the class representing the type of secondary resources to retrieve + * @param eventSourceName the name of the event source to look in (may be {@code null}) + * @param the type of secondary resources to retrieve + * @return a {@link Stream} of secondary resources of the specified type + * @throws io.javaoperatorsdk.operator.processing.event.NoEventSourceForClassException if no event + * source is registered for the given type and name (and no workflow activation condition + * accounts for it) + * @since 5.3.5 + */ + Stream getSecondaryResourcesAsStream(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 ac5a7b41b9..1b846e3f49 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 @@ -36,6 +36,7 @@ import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever; import io.javaoperatorsdk.operator.processing.event.NoEventSourceForClassException; import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.Cache; public class DefaultContext

implements Context

{ private RetryInfo retryInfo; @@ -95,6 +96,20 @@ public Stream getSecondaryResourcesAsStream(Class expectedType, boolea } } + /** + * Whether a missing event source for the given type is the expected case, in which case callers + * should return an empty result instead of propagating the {@link + * NoEventSourceForClassException}. + * + *

If a workflow has an activation condition there can be event sources which are only + * registered if the activation condition holds, but to provide a consistent API we return an + * empty result instead of throwing an exception. Note that not only the resource which has an + * activation condition might not be registered but dependents which depend on it. + */ + private boolean isMissingEventSourceExpected(String eventSourceName, Class expectedType) { + return eventSourceName == null && controller.workflowContainsDependentForType(expectedType); + } + private Map deduplicatedMap(Stream stream) { return stream.collect( Collectors.toUnmodifiableMap( @@ -120,19 +135,53 @@ public Optional getSecondaryResource(Class expectedType, String eventS .getEventSourceFor(expectedType, eventSourceName) .getSecondaryResource(primaryResource); } catch (NoEventSourceForClassException e) { - /* - * If a workflow has an activation condition there can be event sources which are only - * registered if the activation condition holds, but to provide a consistent API we return an - * Optional instead of throwing an exception. - * - * Note that not only the resource which has an activation condition might not be registered - * but dependents which depend on it. - */ - if (eventSourceName == null && controller.workflowContainsDependentForType(expectedType)) { + if (isMissingEventSourceExpected(eventSourceName, expectedType)) { return Optional.empty(); - } else { - throw e; } + throw e; + } + } + + @Override + public Optional getSecondaryResource( + Class expectedType, String eventSourceName, ResourceID resourceID) { + try { + final var eventSource = + controller.getEventSourceManager().getEventSourceFor(expectedType, eventSourceName); + if (eventSource instanceof Cache cache) { + return cache.get(resourceID).map(expectedType::cast); + } + return eventSource.getSecondaryResources(primaryResource).stream() + .filter(HasMetadata.class::isInstance) + .map(HasMetadata.class::cast) + .filter(r -> ResourceID.fromResource(r).equals(resourceID)) + .findFirst() + .map(expectedType::cast); + } catch (NoEventSourceForClassException e) { + if (isMissingEventSourceExpected(eventSourceName, expectedType)) { + return Optional.empty(); + } + throw e; + } + } + + @Override + public Stream getSecondaryResourcesAsStream( + Class expectedType, String eventSourceName) { + try { + final var eventSource = + controller.getEventSourceManager().getEventSourceFor(expectedType, eventSourceName); + if (eventSource instanceof ResourceCache resourceCache) { + final var ns = primaryResource.getMetadata().getNamespace(); + final Stream stream = ns == null ? resourceCache.list() : resourceCache.list(ns); + return stream.map(expectedType::cast); + } + return eventSource.getSecondaryResources(primaryResource).stream().map(expectedType::cast); + } catch (NoEventSourceForClassException e) { + if (isMissingEventSourceExpected(eventSourceName, expectedType)) { + return Stream.empty(); + } + throw e; } } 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 4df8df385b..4936e2df41 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 @@ -16,26 +16,34 @@ package io.javaoperatorsdk.operator.api.reconciler; import java.util.List; +import java.util.Optional; import java.util.Set; +import java.util.stream.Stream; 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.ConfigMapBuilder; 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.fabric8.kubernetes.api.model.SecretBuilder; 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 io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; 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.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class DefaultContextTest { @@ -63,6 +71,240 @@ void getSecondaryResourceReturnsEmptyOptionalOnNonActivatedDRType() { assertThat(res).isEmpty(); } + @Test + void getSecondaryResourceByResourceIDReturnsFromCacheFastPath() { + final var resourceID = new ResourceID("cm-foo", "ns"); + final var cm = + new ConfigMapBuilder() + .withNewMetadata() + .withName("cm-foo") + .withNamespace("ns") + .endMetadata() + .build(); + + final ManagedInformerEventSource cachingEventSource = mock(); + when(cachingEventSource.get(resourceID)).thenReturn(Optional.of(cm)); + when(mockManager.getEventSourceFor(ConfigMap.class, "es-name")).thenReturn(cachingEventSource); + + final var res = context.getSecondaryResource(ConfigMap.class, "es-name", resourceID); + + assertThat(res).contains(cm); + verify(cachingEventSource).get(resourceID); + } + + @Test + void getSecondaryResourceByResourceIDReturnsEmptyOnCacheMiss() { + final var resourceID = new ResourceID("missing", "ns"); + + final ManagedInformerEventSource cachingEventSource = mock(); + when(cachingEventSource.get(resourceID)).thenReturn(Optional.empty()); + when(mockManager.getEventSourceFor(ConfigMap.class, "es-name")).thenReturn(cachingEventSource); + + assertThat(context.getSecondaryResource(ConfigMap.class, "es-name", resourceID)).isEmpty(); + } + + @Test + void getSecondaryResourceByResourceIDFallsBackToGetSecondaryResources() { + final var resourceID = new ResourceID("cm-foo", "ns"); + final var match = + new ConfigMapBuilder() + .withNewMetadata() + .withName("cm-foo") + .withNamespace("ns") + .endMetadata() + .build(); + final var other = + new ConfigMapBuilder() + .withNewMetadata() + .withName("cm-bar") + .withNamespace("ns") + .endMetadata() + .build(); + + final EventSource nonCachingEventSource = mock(); + when(nonCachingEventSource.getSecondaryResources(any())).thenReturn(Set.of(match, other)); + when(mockManager.getEventSourceFor(ConfigMap.class, "es-name")) + .thenReturn(nonCachingEventSource); + + final var res = context.getSecondaryResource(ConfigMap.class, "es-name", resourceID); + + assertThat(res).contains(match); + } + + @Test + void getSecondaryResourceByResourceIDFallbackReturnsEmptyWhenNoMatch() { + final var resourceID = new ResourceID("missing", "ns"); + final var other = + new ConfigMapBuilder() + .withNewMetadata() + .withName("cm-other") + .withNamespace("ns") + .endMetadata() + .build(); + + final EventSource nonCachingEventSource = mock(); + when(nonCachingEventSource.getSecondaryResources(any())).thenReturn(Set.of(other)); + when(mockManager.getEventSourceFor(ConfigMap.class, "es-name")) + .thenReturn(nonCachingEventSource); + + assertThat(context.getSecondaryResource(ConfigMap.class, "es-name", resourceID)).isEmpty(); + } + + @Test + void getSecondaryResourceByResourceIDRethrowsWhenNoEventSourceAndNotWorkflowManaged() { + final var resourceID = new ResourceID("cm-foo", "ns"); + when(mockManager.getEventSourceFor(ConfigMap.class, "es-name")) + .thenThrow(new NoEventSourceForClassException(ConfigMap.class)); + + assertThatThrownBy(() -> context.getSecondaryResource(ConfigMap.class, "es-name", resourceID)) + .isInstanceOf(NoEventSourceForClassException.class); + } + + @Test + void getSecondaryResourceByResourceIDReturnsEmptyWhenNoEventSourceButWorkflowManaged() { + final var resourceID = new ResourceID("cm-foo", "ns"); + when(mockManager.getEventSourceFor(ConfigMap.class, null)) + .thenThrow(new NoEventSourceForClassException(ConfigMap.class)); + when(mockController.workflowContainsDependentForType(ConfigMap.class)).thenReturn(true); + + final var res = context.getSecondaryResource(ConfigMap.class, null, resourceID); + + assertThat(res).isEmpty(); + } + + @Test + void getSecondaryResourceByNameUsesPrimaryNamespace() { + final var primaryNamespace = "primary-ns"; + final var namespacedPrimary = + new SecretBuilder() + .withNewMetadata() + .withName("primary") + .withNamespace(primaryNamespace) + .endMetadata() + .build(); + final DefaultContext namespacedContext = + new DefaultContext<>(null, mockController, namespacedPrimary, false, false); + + final var cm = + new ConfigMapBuilder() + .withNewMetadata() + .withName("cm-foo") + .withNamespace(primaryNamespace) + .endMetadata() + .build(); + + final ManagedInformerEventSource cachingEventSource = mock(); + when(cachingEventSource.get(new ResourceID("cm-foo", primaryNamespace))) + .thenReturn(Optional.of(cm)); + when(mockManager.getEventSourceFor(ConfigMap.class, "es-name")).thenReturn(cachingEventSource); + + final var res = namespacedContext.getSecondaryResource(ConfigMap.class, "es-name", "cm-foo"); + + assertThat(res).contains(cm); + } + + @Test + void getSecondaryResourcesAsStreamByEventSourceUsesResourceCacheFastPath() { + final var primaryNamespace = "primary-ns"; + final var namespacedPrimary = + new SecretBuilder() + .withNewMetadata() + .withName("primary") + .withNamespace(primaryNamespace) + .endMetadata() + .build(); + final DefaultContext namespacedContext = + new DefaultContext<>(null, mockController, namespacedPrimary, false, false); + + final var cm1 = + new ConfigMapBuilder() + .withNewMetadata() + .withName("cm-1") + .withNamespace(primaryNamespace) + .endMetadata() + .build(); + final var cm2 = + new ConfigMapBuilder() + .withNewMetadata() + .withName("cm-2") + .withNamespace(primaryNamespace) + .endMetadata() + .build(); + + final ManagedInformerEventSource resourceCacheEventSource = mock(); + when(resourceCacheEventSource.list(primaryNamespace)).thenReturn(Stream.of(cm1, cm2)); + when(mockManager.getEventSourceFor(ConfigMap.class, "es-name")) + .thenReturn(resourceCacheEventSource); + + final var res = + namespacedContext.getSecondaryResourcesAsStream(ConfigMap.class, "es-name").toList(); + + assertThat(res).containsExactlyInAnyOrder(cm1, cm2); + verify(resourceCacheEventSource).list(primaryNamespace); + } + + @Test + void getSecondaryResourcesAsStreamByEventSourceFastPathOnClusterScopedPrimary() { + // cluster-scoped primary: has metadata but no namespace set. + final var clusterScopedPrimary = + new SecretBuilder().withNewMetadata().withName("primary").endMetadata().build(); + final DefaultContext clusterScopedContext = + new DefaultContext<>(null, mockController, clusterScopedPrimary, false, false); + + final var cm1 = new ConfigMapBuilder().withNewMetadata().withName("cm-1").endMetadata().build(); + + final ManagedInformerEventSource resourceCacheEventSource = mock(); + when(resourceCacheEventSource.list()).thenReturn(Stream.of(cm1)); + when(mockManager.getEventSourceFor(ConfigMap.class, "es-name")) + .thenReturn(resourceCacheEventSource); + + final var res = + clusterScopedContext.getSecondaryResourcesAsStream(ConfigMap.class, "es-name").toList(); + + assertThat(res).containsExactly(cm1); + verify(resourceCacheEventSource).list(); + verify(resourceCacheEventSource, never()).list(any(String.class)); + } + + @Test + void getSecondaryResourcesAsStreamByEventSourceFallsBackToGetSecondaryResources() { + final var cm1 = + new ConfigMapBuilder() + .withNewMetadata() + .withName("cm-1") + .withNamespace("ns") + .endMetadata() + .build(); + + final EventSource nonCacheEventSource = mock(); + when(nonCacheEventSource.getSecondaryResources(any())).thenReturn(Set.of(cm1)); + when(mockManager.getEventSourceFor(ConfigMap.class, "es-name")).thenReturn(nonCacheEventSource); + + final var res = context.getSecondaryResourcesAsStream(ConfigMap.class, "es-name").toList(); + + assertThat(res).containsExactly(cm1); + } + + @Test + void getSecondaryResourcesAsStreamByEventSourceRethrowsWhenNotWorkflowManaged() { + when(mockManager.getEventSourceFor(ConfigMap.class, "es-name")) + .thenThrow(new NoEventSourceForClassException(ConfigMap.class)); + + assertThatThrownBy(() -> context.getSecondaryResourcesAsStream(ConfigMap.class, "es-name")) + .isInstanceOf(NoEventSourceForClassException.class); + } + + @Test + void getSecondaryResourcesAsStreamByEventSourceReturnsEmptyWhenWorkflowManaged() { + when(mockManager.getEventSourceFor(ConfigMap.class, null)) + .thenThrow(new NoEventSourceForClassException(ConfigMap.class)); + when(mockController.workflowContainsDependentForType(ConfigMap.class)).thenReturn(true); + + final var res = context.getSecondaryResourcesAsStream(ConfigMap.class, null).toList(); + + assertThat(res).isEmpty(); + } + @Test void setRetryInfo() { RetryInfo retryInfo = mock();