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();