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..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 @@ -115,15 +115,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 +362,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);