From 3c6ce5f0ae053651555394f78a438bf57f9a202a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 31 Aug 2026 09:32:51 +0200 Subject: [PATCH 1/2] refactor: configure the event recorder on the operator instead of the controller Removes EventRecorder from RegisteredController and makes the instance the controllers record their Kubernetes events through configurable for the whole operator, via ConfigurationService.eventRecorder() / ConfigurationServiceOverrider.withEventRecorder(). When none is configured, each controller keeps recording through a DefaultEventRecorder of its own, which attributes the events to that controller, as before. Recording events outside of a reconciliation is now done through the configured instance, which the caller owns, rather than through one handed out by the registered controller. --- .../operator/RegisteredController.java | 14 -------- .../api/config/ConfigurationService.java | 20 ++++++++++++ .../config/ConfigurationServiceOverrider.java | 26 +++++++++++++++ .../operator/api/event/EventRecorder.java | 13 +++++--- .../operator/api/reconciler/Context.java | 5 +-- .../operator/processing/Controller.java | 29 +++++++++++------ .../ConfigurationServiceOverriderTest.java | 25 +++++++++++++++ .../operator/processing/ControllerTest.java | 32 +++++++++++++++++++ 8 files changed, 134 insertions(+), 30 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RegisteredController.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RegisteredController.java index e6aa6cbce6..ac5b7cd468 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RegisteredController.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RegisteredController.java @@ -18,7 +18,6 @@ import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.config.NamespaceChangeable; -import io.javaoperatorsdk.operator.api.event.EventRecorder; import io.javaoperatorsdk.operator.health.ControllerHealthInfo; public interface RegisteredController

extends NamespaceChangeable { @@ -26,17 +25,4 @@ public interface RegisteredController

extends NamespaceCh ControllerConfiguration

getConfiguration(); ControllerHealthInfo getControllerHealthInfo(); - - /** - * Returns the {@link EventRecorder} of this controller, to record Kubernetes events outside of a - * reconciliation, for example from a status listener or a background task. Within a - * reconciliation, use {@link io.javaoperatorsdk.operator.api.reconciler.Context#eventRecorder()} - * instead. - * - * @return the event recorder associated with this controller - */ - default EventRecorder eventRecorder() { - throw new UnsupportedOperationException( - "This implementation of RegisteredController does not provide an EventRecorder"); - } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java index 0b1d6b47cb..d42cf3c6fc 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java @@ -35,6 +35,7 @@ import io.fabric8.kubernetes.client.KubernetesClientBuilder; import io.fabric8.kubernetes.client.utils.KubernetesSerialization; import io.javaoperatorsdk.operator.api.event.DefaultEventRecorder; +import io.javaoperatorsdk.operator.api.event.EventRecorder; import io.javaoperatorsdk.operator.api.monitoring.Metrics; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.Experimental; @@ -295,6 +296,25 @@ default String clusterScopedEventNamespace() { return DefaultEventRecorder.CLUSTER_SCOPED_EVENT_NAMESPACE; } + /** + * The {@link EventRecorder} the controllers of the operator record their Kubernetes events + * through, to plug in a custom implementation, for example one that assembles events differently + * by extending {@link DefaultEventRecorder}, or one that records them somewhere else entirely. + * + *

When empty, which is the default, every controller gets a {@link DefaultEventRecorder} of + * its own, which attributes the events it records to that controller. A recorder configured here + * is shared by all controllers of the operator, so it decides on its own what the events it + * records are attributed to, and it is up to the caller to hold on to the instance if it also + * records events outside of a reconciliation. + * + * @return the event recorder to use for the whole operator, or an empty optional to let each + * controller use its own default one + */ + @Experimental(Experimental.API_MIGHT_CHANGE) + default Optional eventRecorder() { + return Optional.empty(); + } + /** * if true, operator stops if there are some issues with informers {@link * io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource} or {@link diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java index 9f0fd78356..944200c742 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java @@ -27,6 +27,7 @@ import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.client.KubernetesClient; import io.javaoperatorsdk.operator.Operator; +import io.javaoperatorsdk.operator.api.event.EventRecorder; import io.javaoperatorsdk.operator.api.monitoring.Metrics; import io.javaoperatorsdk.operator.api.reconciler.Experimental; import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory; @@ -48,6 +49,7 @@ public class ConfigurationServiceOverrider { private ExecutorService workflowExecutorService; private LeaderElectionConfiguration leaderElectionConfiguration; private String clusterScopedEventNamespace; + private EventRecorder eventRecorder; private InformerStoppedHandler informerStoppedHandler; private Boolean stopOnInformerErrorDuringStartup; private Duration cacheSyncTimeout; @@ -148,6 +150,25 @@ public ConfigurationServiceOverrider withClusterScopedEventNamespace(String name return this; } + /** + * Replaces the {@link EventRecorder} the controllers of the operator record their Kubernetes + * events through by the specified one, which is then shared by all of them. Use this to record + * events differently, for example through a subclass of {@link + * io.javaoperatorsdk.operator.api.event.DefaultEventRecorder} that assembles them another way, or + * to hold on to the recorder in order to also record events outside of a reconciliation. + * + *

When not set, every controller records its events through a recorder of its own, which + * attributes them to that controller. + * + * @param eventRecorder the event recorder to use for the whole operator + * @return this {@link ConfigurationServiceOverrider} for chained customization + */ + @Experimental(Experimental.API_MIGHT_CHANGE) + public ConfigurationServiceOverrider withEventRecorder(EventRecorder eventRecorder) { + this.eventRecorder = eventRecorder; + return this; + } + public ConfigurationServiceOverrider withInformerStoppedHandler(InformerStoppedHandler handler) { this.informerStoppedHandler = handler; return this; @@ -297,6 +318,11 @@ public String clusterScopedEventNamespace() { : original.clusterScopedEventNamespace(); } + @Override + public Optional eventRecorder() { + return eventRecorder != null ? Optional.of(eventRecorder) : original.eventRecorder(); + } + @Override public Optional getInformerStoppedHandler() { return informerStoppedHandler != null diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/EventRecorder.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/EventRecorder.java index a7d66d8e54..c4d4919508 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/EventRecorder.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/EventRecorder.java @@ -16,20 +16,25 @@ package io.javaoperatorsdk.operator.api.event; import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Experimental; + +import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE; /** * Records Kubernetes events on behalf of a controller. * *

This is the unbound form of the API: it is scoped to a controller, not to a reconciliation, * and can therefore be used outside of the reconciliation loop, for example from a status listener - * or a background task. Obtain it from {@link - * io.javaoperatorsdk.operator.RegisteredController#eventRecorder()}. Within a reconciliation, - * prefer {@link io.javaoperatorsdk.operator.api.reconciler.Context#eventRecorder()}, which is - * already bound to the primary resource. + * or a background task. To use it that way, configure the instance the operator records its events + * through, see {@link io.javaoperatorsdk.operator.api.config.ConfigurationService#eventRecorder()}, + * and keep a reference to it. Within a reconciliation, prefer {@link + * io.javaoperatorsdk.operator.api.reconciler.Context#eventRecorder()}, which is already bound to + * the primary resource. * *

Recording an event is best effort: failures to write the event to the cluster are logged and * swallowed, and never fail the caller. */ +@Experimental(API_MIGHT_CHANGE) public interface EventRecorder { /** 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 df9c19b263..7c10c0963e 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 @@ -216,8 +216,9 @@ default Optional getSecondaryResource( /** * Returns a {@link ResourceEventRecorder} bound to the primary resource, to record Kubernetes - * events about it. To record events outside of a reconciliation, or about another object, use - * {@link io.javaoperatorsdk.operator.RegisteredController#eventRecorder()}. + * events about it. To record events outside of a reconciliation, or about another object, use an + * {@link io.javaoperatorsdk.operator.api.event.EventRecorder} configured for the operator, see + * {@link io.javaoperatorsdk.operator.api.config.ConfigurationService#eventRecorder()}. * * @return an event recorder bound to the primary resource * @throws UnsupportedOperationException if the implementation does not provide an event recorder diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java index 285eb3988c..43cb0a8a83 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java @@ -36,6 +36,7 @@ import io.javaoperatorsdk.operator.MissingCRDException; import io.javaoperatorsdk.operator.OperatorException; import io.javaoperatorsdk.operator.RegisteredController; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.config.ExecutorServiceManager; import io.javaoperatorsdk.operator.api.config.LeaderElectionConfiguration; @@ -115,15 +116,19 @@ public Controller( this.kubernetesClient = kubernetesClient; this.metrics = Optional.ofNullable(configurationService.getMetrics()).orElse(Metrics.NOOP); this.eventRecorder = - new DefaultEventRecorder( - configuration.getName(), - configurationService - .getLeaderElectionConfiguration() - .flatMap(LeaderElectionConfiguration::getIdentity) - .orElseGet(DefaultEventRecorder::defaultReportingInstance), - Optional.ofNullable(configurationService.clusterScopedEventNamespace()) - .orElse(DefaultEventRecorder.CLUSTER_SCOPED_EVENT_NAMESPACE), - new DefaultEventSink(kubernetesClient)); + configurationService + .eventRecorder() + .orElseGet( + () -> + new DefaultEventRecorder( + configuration.getName(), + configurationService + .getLeaderElectionConfiguration() + .flatMap(LeaderElectionConfiguration::getIdentity) + .orElseGet(DefaultEventRecorder::defaultReportingInstance), + Optional.ofNullable(configurationService.clusterScopedEventNamespace()) + .orElse(DefaultEventRecorder.CLUSTER_SCOPED_EVENT_NAMESPACE), + new DefaultEventSink(kubernetesClient))); contextInitializer = reconciler instanceof ContextInitializer; isCleaner = reconciler instanceof Cleaner; @@ -358,7 +363,11 @@ public ControllerHealthInfo getControllerHealthInfo() { return controllerHealthInfo; } - @Override + /** + * The {@link EventRecorder} this controller records its Kubernetes events through, either the one + * configured for the operator, see {@link ConfigurationService#eventRecorder()}, or a {@link + * DefaultEventRecorder} of its own. + */ public EventRecorder eventRecorder() { return eventRecorder; } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverriderTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverriderTest.java index 9df62bc03c..9d5db71c6f 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverriderTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverriderTest.java @@ -23,6 +23,9 @@ import org.junit.jupiter.api.Test; import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.event.EventRecord; +import io.javaoperatorsdk.operator.api.event.EventRecorder; +import io.javaoperatorsdk.operator.api.event.ResourceEventRecorder; import io.javaoperatorsdk.operator.api.monitoring.Metrics; import static org.assertj.core.api.Assertions.assertThat; @@ -106,6 +109,28 @@ public R clone(R object) { config.reconciliationTerminationTimeout(), overridden.reconciliationTerminationTimeout()); } + @Test + void eventRecorderIsNotConfiguredByDefaultAndCanBeOverridden() { + final var eventRecorder = + new EventRecorder() { + @Override + public void record(HasMetadata regarding, EventRecord event) {} + + @Override + public ResourceEventRecorder forResource(HasMetadata regarding) { + return null; + } + }; + + assertThat(config.eventRecorder()).isEmpty(); + assertThat( + new ConfigurationServiceOverrider(config) + .withEventRecorder(eventRecorder) + .build() + .eventRecorder()) + .contains(eventRecorder); + } + @Test void threadCountConfiguredProperly() { final var overridden = diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/ControllerTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/ControllerTest.java index b725f49132..de582f593d 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/ControllerTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/ControllerTest.java @@ -28,6 +28,8 @@ import io.javaoperatorsdk.operator.api.config.ConfigurationService; import io.javaoperatorsdk.operator.api.config.MockControllerConfiguration; import io.javaoperatorsdk.operator.api.config.workflow.WorkflowSpec; +import io.javaoperatorsdk.operator.api.event.DefaultEventRecorder; +import io.javaoperatorsdk.operator.api.event.EventRecorder; import io.javaoperatorsdk.operator.api.monitoring.Metrics; import io.javaoperatorsdk.operator.api.reconciler.Cleaner; import io.javaoperatorsdk.operator.api.reconciler.DefaultContext; @@ -110,6 +112,36 @@ void doesNotNotifyMetricsWhenEventProcessorNotStarted() { verify(metrics, never()).eventProcessingStarted(controller); } + @Test + void recordsEventsThroughTheEventRecorderConfiguredForTheOperator() { + final var client = MockKubernetesClient.client(Secret.class); + final var eventRecorder = mock(EventRecorder.class); + final var configurationService = + ConfigurationService.newOverriddenConfigurationService( + new BaseConfigurationService(), + o -> o.withEventRecorder(eventRecorder).withKubernetesClient(client)); + final var configuration = + MockControllerConfiguration.forResource(Secret.class, configurationService); + + final var controller = new Controller(reconciler, configuration, client); + + assertThat(controller.eventRecorder()).isSameAs(eventRecorder); + } + + @Test + void recordsEventsThroughAnEventRecorderOfItsOwnWhenNoneIsConfigured() { + final var client = MockKubernetesClient.client(Secret.class); + final var configuration = + MockControllerConfiguration.forResource( + Secret.class, + ConfigurationService.newOverriddenConfigurationService( + new BaseConfigurationService(), o -> o.withKubernetesClient(client))); + + final var controller = new Controller(reconciler, configuration, client); + + assertThat(controller.eventRecorder()).isInstanceOf(DefaultEventRecorder.class); + } + @Test void crdShouldNotBeCheckedForCustomResourcesIfDisabled() { final var client = MockKubernetesClient.client(TestCustomResource.class); From ce3f75d833411be40a3a04d0e0ac6a327b7cb561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 31 Aug 2026 13:44:07 +0200 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../java/io/javaoperatorsdk/operator/processing/Controller.java | 1 - 1 file changed, 1 deletion(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java index 43cb0a8a83..bdd858e813 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java @@ -36,7 +36,6 @@ import io.javaoperatorsdk.operator.MissingCRDException; import io.javaoperatorsdk.operator.OperatorException; import io.javaoperatorsdk.operator.RegisteredController; -import io.javaoperatorsdk.operator.api.config.ConfigurationService; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.config.ExecutorServiceManager; import io.javaoperatorsdk.operator.api.config.LeaderElectionConfiguration;