diff --git a/docs/content/en/docs/documentation/operations/configuration.md b/docs/content/en/docs/documentation/operations/configuration.md
index 513cc432d8..a8ab6b932f 100644
--- a/docs/content/en/docs/documentation/operations/configuration.md
+++ b/docs/content/en/docs/documentation/operations/configuration.md
@@ -294,6 +294,12 @@ All operator-level keys are prefixed with `josdk.`.
|---|---|---|
| `josdk.dependent-resources.ssa-based-create-update-match` | `Boolean` | Use SSA-based matching for dependent resource create/update |
+#### Events
+
+| Key | Type | Description |
+|---|---|---|
+| `josdk.events.cluster-scoped-namespace` | `String` | Namespace to record events about cluster scoped resources in (defaults to `default`) |
+
#### Leader Election
Leader election is activated when at least one `josdk.leader-election.*` key is present.
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 ac5b7cd468..b8ffb92ac1 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,6 +18,7 @@
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.events.EventRecorder;
import io.javaoperatorsdk.operator.health.ControllerHealthInfo;
public interface RegisteredController
extends NamespaceChangeable {
@@ -25,4 +26,17 @@ 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 6ed9b7ff64..dc0ebf2c90 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
@@ -34,6 +34,7 @@
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.KubernetesClientBuilder;
import io.fabric8.kubernetes.client.utils.KubernetesSerialization;
+import io.javaoperatorsdk.operator.api.events.DefaultEventRecorder;
import io.javaoperatorsdk.operator.api.monitoring.Metrics;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
@@ -274,6 +275,23 @@ default Optional getLeaderElectionConfiguration() {
return Optional.empty();
}
+ /**
+ * The namespace in which Kubernetes events about cluster scoped resources are created, since such
+ * events still have to live in some namespace.
+ *
+ * Defaults to the {@code default} namespace, following the Kubernetes convention, which is
+ * also what the Go client does, so that events recorded by an operator end up alongside the ones
+ * recorded by the built-in controllers. Operators whose RBAC does not allow creating events in
+ * the {@code default} namespace should override this, typically with the namespace the operator
+ * itself runs in. Note that recording an event is best effort, so a missing permission results in
+ * the event being dropped with a warning in the log rather than in an error.
+ *
+ * @return the namespace to record events about cluster scoped resources in
+ */
+ default String clusterScopedEventNamespace() {
+ return DefaultEventRecorder.CLUSTER_SCOPED_EVENT_NAMESPACE;
+ }
+
/**
* 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 cd9cdafb39..c67af2be99 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
@@ -45,6 +45,7 @@ public class ConfigurationServiceOverrider {
private ExecutorService executorService;
private ExecutorService workflowExecutorService;
private LeaderElectionConfiguration leaderElectionConfiguration;
+ private String clusterScopedEventNamespace;
private InformerStoppedHandler informerStoppedHandler;
private Boolean stopOnInformerErrorDuringStartup;
private Duration cacheSyncTimeout;
@@ -131,6 +132,19 @@ public ConfigurationServiceOverrider withLeaderElectionConfiguration(
return this;
}
+ /**
+ * Sets the namespace in which Kubernetes events about cluster scoped resources are recorded. Use
+ * this when the operator is not allowed to create events in the {@code default} namespace,
+ * passing for example the namespace the operator itself runs in.
+ *
+ * @param namespace the namespace to record events about cluster scoped resources in
+ * @return this {@link ConfigurationServiceOverrider} for chained customization
+ */
+ public ConfigurationServiceOverrider withClusterScopedEventNamespace(String namespace) {
+ this.clusterScopedEventNamespace = namespace;
+ return this;
+ }
+
public ConfigurationServiceOverrider withInformerStoppedHandler(InformerStoppedHandler handler) {
this.informerStoppedHandler = handler;
return this;
@@ -258,6 +272,13 @@ public Optional getLeaderElectionConfiguration() {
: original.getLeaderElectionConfiguration();
}
+ @Override
+ public String clusterScopedEventNamespace() {
+ return clusterScopedEventNamespace != null
+ ? clusterScopedEventNamespace
+ : original.clusterScopedEventNamespace();
+ }
+
@Override
public Optional getInformerStoppedHandler() {
return informerStoppedHandler != null
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/DefaultEventRecorder.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/DefaultEventRecorder.java
new file mode 100644
index 0000000000..33bf4e7c49
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/DefaultEventRecorder.java
@@ -0,0 +1,254 @@
+/*
+ * 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.api.events;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.HexFormat;
+import java.util.Objects;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.fabric8.kubernetes.api.model.Event;
+import io.fabric8.kubernetes.api.model.EventBuilder;
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.fabric8.kubernetes.api.model.ObjectReference;
+import io.fabric8.kubernetes.api.model.ObjectReferenceBuilder;
+
+import static java.util.Objects.requireNonNullElse;
+
+/**
+ * Default {@link EventRecorder}. Assembles events from an {@link EventRecord} plus the context the
+ * controller already knows about (the involved object reference, the reporting controller and
+ * instance), then hands them to an {@link EventSink}.
+ *
+ * Events for cluster scoped objects have to live in some namespace: by default the {@value
+ * #CLUSTER_SCOPED_EVENT_NAMESPACE} namespace is used, following the Kubernetes convention, but it
+ * can be overridden, see {@link
+ * io.javaoperatorsdk.operator.api.config.ConfigurationService#clusterScopedEventNamespace()}.
+ *
+ *
Events are named deterministically, after the object they are about plus a hash of everything
+ * that identifies the event, so that recording the same event again resolves to the event already
+ * recorded for it rather than to a duplicate, see {@link DefaultEventSink}.
+ */
+public class DefaultEventRecorder implements EventRecorder {
+
+ private static final Logger log = LoggerFactory.getLogger(DefaultEventRecorder.class);
+
+ public static final String CLUSTER_SCOPED_EVENT_NAMESPACE = "default";
+
+ /**
+ * Kubernetes limits object names to 253 characters, as they have to be valid RFC 1123 DNS
+ * subdomains.
+ */
+ private static final int MAX_NAME_LENGTH = 253;
+
+ /** Separates the parts hashed into the event name, so no two sets of parts can collide. */
+ private static final char IDENTITY_SEPARATOR = '\0';
+
+ /** Digest used to derive the event name suffix from the identity of the event. */
+ private static final String IDENTITY_DIGEST = "SHA-256";
+
+ private static final int IDENTITY_HASH_LENGTH = 32;
+
+ private final String reportingController;
+ private final String reportingInstance;
+ private final String clusterScopedEventNamespace;
+ private final EventSink sink;
+
+ public DefaultEventRecorder(
+ String reportingController, String reportingInstance, EventSink sink) {
+ this(reportingController, reportingInstance, CLUSTER_SCOPED_EVENT_NAMESPACE, sink);
+ }
+
+ public DefaultEventRecorder(
+ String reportingController,
+ String reportingInstance,
+ String clusterScopedEventNamespace,
+ EventSink sink) {
+ this.reportingController = reportingController;
+ this.reportingInstance = reportingInstance;
+ this.clusterScopedEventNamespace = clusterScopedEventNamespace;
+ this.sink = sink;
+ }
+
+ /**
+ * The instance name to report events under, when it is not otherwise configured. Uses the host
+ * name, which for an operator running in a pod is the pod name.
+ */
+ public static String defaultReportingInstance() {
+ var fromEnv = System.getenv("HOSTNAME");
+ if (fromEnv != null && !fromEnv.isBlank()) {
+ return fromEnv;
+ }
+ try {
+ return InetAddress.getLocalHost().getHostName();
+ } catch (UnknownHostException e) {
+ log.debug("Could not determine host name to report events under", e);
+ return "unknown";
+ }
+ }
+
+ @Override
+ public void record(HasMetadata regarding, EventRecord event) {
+ Objects.requireNonNull(regarding, "the object the event is about must not be null");
+ Objects.requireNonNull(event, "event must not be null");
+ try {
+ sink.emit(toEvent(regarding, event));
+ } catch (Exception e) {
+ // recording an event must never break the caller: a controller that fails to reconcile
+ // because it could not write an event is strictly worse than one that records nothing
+ log.warn(
+ "Could not record {} event with reason {} for resource {} in namespace {}",
+ event.type(),
+ event.reason(),
+ regarding.getMetadata().getName(),
+ regarding.getMetadata().getNamespace(),
+ e);
+ }
+ }
+
+ @Override
+ public ResourceEventRecorder forResource(HasMetadata regarding) {
+ Objects.requireNonNull(regarding, "the object events will be about must not be null");
+ return new BoundEventRecorder(this, regarding);
+ }
+
+ protected Event toEvent(HasMetadata regarding, EventRecord record) {
+ var now = Instant.now().truncatedTo(ChronoUnit.SECONDS).toString();
+ var involvedObject = objectReferenceFor(regarding);
+ var builder =
+ new EventBuilder()
+ .withNewMetadata()
+ .withName(eventName(regarding, record))
+ .withNamespace(eventNamespace(regarding))
+ .withLabels(record.labels())
+ .withAnnotations(record.annotations())
+ .endMetadata()
+ .withInvolvedObject(involvedObject)
+ .withType(record.type().value())
+ .withReason(record.reason())
+ .withMessage(record.message())
+ .withFirstTimestamp(now)
+ .withLastTimestamp(now)
+ .withCount(1)
+ .withReportingComponent(record.reportingComponent().orElse(reportingController))
+ .withReportingInstance(reportingInstance)
+ // the deprecated source is still what kubectl renders in the "From" column
+ .withNewSource()
+ .withComponent(record.reportingComponent().orElse(reportingController))
+ .endSource();
+ record.action().ifPresent(builder::withAction);
+ return builder.build();
+ }
+
+ private String eventNamespace(HasMetadata regarding) {
+ var namespace = regarding.getMetadata().getNamespace();
+ return namespace == null ? clusterScopedEventNamespace : namespace;
+ }
+
+ /**
+ * Names events {@code .}, following the convention of the Go client, hashing
+ * everything that makes two events the same event: the object, the type, the reason, the
+ * reporting component and, unless the record sets a {@link EventRecord#key()}, the message. The
+ * name is therefore stable across occurrences, which is what lets the sink recognise a repeat,
+ * and stays so across operator restarts and between replicas, unlike a name remembered in memory.
+ *
+ * The object is identified by its uid, with the kind as a fallback for objects that do not
+ * have one yet, such as a dependent resource that has only been built so far.
+ */
+ private String eventName(HasMetadata regarding, EventRecord record) {
+ var metadata = regarding.getMetadata();
+ var identity =
+ String.join(
+ String.valueOf(IDENTITY_SEPARATOR),
+ requireNonNullElse(regarding.getKind(), ""),
+ requireNonNullElse(metadata.getUid(), ""),
+ record.type().value(),
+ record.reason(),
+ record.reportingComponent().orElse(reportingController),
+ record.key().orElseGet(() -> requireNonNullElse(record.message(), "")));
+
+ var suffix = "." + identityDigest(identity);
+ var prefix = metadata.getName();
+ var maxPrefixLength = MAX_NAME_LENGTH - suffix.length();
+ if (prefix.length() > maxPrefixLength) {
+ prefix = prefix.substring(0, maxPrefixLength);
+ }
+ return prefix + suffix;
+ }
+
+ /**
+ * Digests the contents of the identity of an event into lowercase hexadecimal, which is
+ * valid in an RFC 1123 DNS subdomain. Being a digest of the contents, it is the same in every
+ * process and on every machine for the same event, which is what makes the event name stable
+ * across restarts and between replicas.
+ *
+ *
A cryptographic digest is used rather than {@link String#hashCode()}: the latter collides on
+ * inputs as short as {@code Aa} and {@code BB}, and two colliding events would resolve to the same
+ * name, so the sink would take the second one for a repeat of the first and drop it.
+ *
+ *
A {@link MessageDigest} is created per call on purpose, as it is stateful and not thread
+ * safe; sharing one across concurrent reconciliations would interleave their digests.
+ */
+ private static String identityDigest(String identity) {
+ try {
+ var digest =
+ MessageDigest.getInstance(IDENTITY_DIGEST)
+ .digest(identity.getBytes(StandardCharsets.UTF_8));
+ return HexFormat.of().formatHex(digest).substring(0, IDENTITY_HASH_LENGTH);
+ } catch (NoSuchAlgorithmException e) {
+ // every JVM is required to provide SHA-256
+ throw new IllegalStateException(IDENTITY_DIGEST + " is not available", e);
+ }
+ }
+
+ private ObjectReference objectReferenceFor(HasMetadata resource) {
+ return new ObjectReferenceBuilder()
+ .withApiVersion(resource.getApiVersion())
+ .withKind(resource.getKind())
+ .withName(resource.getMetadata().getName())
+ .withNamespace(resource.getMetadata().getNamespace())
+ .withUid(resource.getMetadata().getUid())
+ .withResourceVersion(resource.getMetadata().getResourceVersion())
+ .build();
+ }
+
+ private record BoundEventRecorder(EventRecorder delegate, HasMetadata regarding)
+ implements ResourceEventRecorder {
+
+ @Override
+ public void normal(String reason, String message) {
+ record(EventRecord.normal(reason, message));
+ }
+
+ @Override
+ public void warn(String reason, String message) {
+ record(EventRecord.warning(reason, message));
+ }
+
+ @Override
+ public void record(EventRecord event) {
+ delegate.record(regarding, event);
+ }
+ }
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/DefaultEventSink.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/DefaultEventSink.java
new file mode 100644
index 0000000000..6b57052a8b
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/DefaultEventSink.java
@@ -0,0 +1,46 @@
+/*
+ * 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.api.events;
+
+import io.fabric8.kubernetes.api.model.Event;
+import io.fabric8.kubernetes.client.KubernetesClient;
+
+/**
+ * Default {@link EventSink}, creating events in the {@code v1} (core) API group. The core group is
+ * used rather than {@code events.k8s.io/v1} because it is what {@code kubectl describe} renders
+ * uniformly and what the count based aggregation of the Kubernetes event model is defined on.
+ *
+ *
An event is only created if it does not exist yet.Should another writer create the event
+ * between the lookup and the create, the resulting conflict is left to the caller, which is
+ * expected to treat recording as best effort.
+ */
+public class DefaultEventSink implements EventSink {
+
+ private final KubernetesClient client;
+
+ public DefaultEventSink(KubernetesClient client) {
+ this.client = client;
+ }
+
+ @Override
+ public void emit(Event event) {
+ var events = client.v1().events().inNamespace(event.getMetadata().getNamespace());
+ var existing = events.withName(event.getMetadata().getName()).get();
+ if (existing == null) {
+ events.resource(event).create();
+ }
+ }
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventRecord.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventRecord.java
new file mode 100644
index 0000000000..0c0cf7bcd8
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventRecord.java
@@ -0,0 +1,188 @@
+/*
+ * 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.api.events;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Describes a Kubernetes event to be recorded. Fields that can be derived from the controller and
+ * the object the event is about (such as the reporting controller and instance, or the involved
+ * object reference) are filled in by the {@link EventRecorder} and are intentionally absent here.
+ *
+ *
Instances are immutable, create them using {@link #builder()}.
+ */
+public final class EventRecord {
+
+ private final EventType type;
+ private final String reason;
+ private final String message;
+ private final String key;
+ private final String action;
+ private final String reportingComponent;
+ private final Map labels;
+ private final Map annotations;
+
+ private EventRecord(Builder builder) {
+ this.type = builder.type;
+ this.reason = builder.reason;
+ this.message = builder.message;
+ this.key = builder.key;
+ this.action = builder.action;
+ this.reportingComponent = builder.reportingComponent;
+ this.labels = Map.copyOf(builder.labels);
+ this.annotations = Map.copyOf(builder.annotations);
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /** Shorthand for a {@link EventType#NORMAL} event. */
+ public static EventRecord normal(String reason, String message) {
+ return builder().type(EventType.NORMAL).reason(reason).message(message).build();
+ }
+
+ /** Shorthand for a {@link EventType#WARNING} event. */
+ public static EventRecord warning(String reason, String message) {
+ return builder().type(EventType.WARNING).reason(reason).message(message).build();
+ }
+
+ public EventType type() {
+ return type;
+ }
+
+ public String reason() {
+ return reason;
+ }
+
+ public String message() {
+ return message;
+ }
+
+ /**
+ * Identifies this event among the events about the same object, so that repeated occurrences
+ * resolve to the same event rather than to one event each.
+ */
+ public Optional key() {
+ return Optional.ofNullable(key);
+ }
+
+ /**
+ * The action taken or failed regarding the involved object, if any. Optional, and only meaningful
+ * for consumers that read the {@code action} field of the event.
+ */
+ public Optional action() {
+ return Optional.ofNullable(action);
+ }
+
+ /**
+ * The component of the operator reporting this event. Set per event, since a single controller
+ * can report on behalf of several logical components. When absent, the recorder uses the
+ * controller name.
+ */
+ public Optional reportingComponent() {
+ return Optional.ofNullable(reportingComponent);
+ }
+
+ public Map labels() {
+ return labels;
+ }
+
+ public Map annotations() {
+ return annotations;
+ }
+
+ @Override
+ public String toString() {
+ return "EventRecord{type=" + type + ", reason=" + reason + ", message=" + message + "}";
+ }
+
+ /** Builder for {@link EventRecord}. */
+ public static final class Builder {
+
+ private EventType type = EventType.NORMAL;
+ private String reason;
+ private String message;
+ private String key;
+ private String action;
+ private String reportingComponent;
+ private final Map labels = new HashMap<>();
+ private final Map annotations = new HashMap<>();
+
+ private Builder() {}
+
+ public Builder type(EventType type) {
+ this.type = Objects.requireNonNull(type, "type must not be null");
+ return this;
+ }
+
+ public Builder reason(String reason) {
+ this.reason = reason;
+ return this;
+ }
+
+ public Builder message(String message) {
+ this.message = message;
+ return this;
+ }
+
+ /** Sets the key identifying this event, see {@link EventRecord#key()}. */
+ public Builder key(String key) {
+ this.key = key;
+ return this;
+ }
+
+ public Builder action(String action) {
+ this.action = action;
+ return this;
+ }
+
+ public Builder reportingComponent(String reportingComponent) {
+ this.reportingComponent = reportingComponent;
+ return this;
+ }
+
+ public Builder label(String key, String value) {
+ this.labels.put(key, value);
+ return this;
+ }
+
+ public Builder labels(Map labels) {
+ this.labels.putAll(labels);
+ return this;
+ }
+
+ public Builder annotation(String key, String value) {
+ this.annotations.put(key, value);
+ return this;
+ }
+
+ public Builder annotations(Map annotations) {
+ this.annotations.putAll(annotations);
+ return this;
+ }
+
+ public EventRecord build() {
+ if (reason == null || reason.isBlank()) {
+ throw new IllegalArgumentException("reason must be set on an event record");
+ }
+ return new EventRecord(this);
+ }
+ }
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventRecorder.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventRecorder.java
new file mode 100644
index 0000000000..be6ba6c357
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventRecorder.java
@@ -0,0 +1,52 @@
+/*
+ * 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.api.events;
+
+import io.fabric8.kubernetes.api.model.HasMetadata;
+
+/**
+ * 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.
+ *
+ *
Recording an event is best effort: failures to write the event to the cluster are logged and
+ * swallowed, and never fail the caller.
+ */
+public interface EventRecorder {
+
+ /**
+ * Records an event about the given object.
+ *
+ * @param regarding the object the event is about; it will be referenced as the involved object of
+ * the resulting event
+ * @param event the event to record
+ */
+ void record(HasMetadata regarding, EventRecord event);
+
+ /**
+ * Returns a view of this recorder bound to the given object, so that the object doesn't have to
+ * be passed for every event.
+ *
+ * @param regarding the object subsequent events will be about
+ * @return a recorder bound to {@code regarding}
+ */
+ ResourceEventRecorder forResource(HasMetadata regarding);
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventSink.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventSink.java
new file mode 100644
index 0000000000..051eb56b36
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventSink.java
@@ -0,0 +1,35 @@
+/*
+ * 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.api.events;
+
+import io.fabric8.kubernetes.api.model.Event;
+
+/**
+ * Writes fully built events somewhere. Extracted from {@link EventRecorder} so that the assembly of
+ * the event and its delivery can be tested and replaced independently. Note that events are
+ * deliberately not written through {@link
+ * io.javaoperatorsdk.operator.api.reconciler.ResourceOperations}: nothing observes events through
+ * an informer, so there is no cache to keep primed.
+ */
+public interface EventSink {
+
+ /**
+ * Delivers the event.
+ *
+ * @param event the event to deliver
+ */
+ void emit(Event event);
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventType.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventType.java
new file mode 100644
index 0000000000..bd82b09e8e
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventType.java
@@ -0,0 +1,42 @@
+/*
+ * 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.api.events;
+
+/**
+ * Type of a recorded Kubernetes event.
+ *
+ *
Kubernetes models this field as a free form string, but only these two values are meaningful:
+ * tooling filters on them, so a value outside this set produces an event that is silently ignored
+ * by anything looking for warnings. Hence the closed set here, unlike the reason of an event, which
+ * is domain specific and therefore a plain string.
+ */
+public enum EventType {
+ NORMAL("Normal"),
+ WARNING("Warning");
+
+ private final String value;
+
+ EventType(String value) {
+ this.value = value;
+ }
+
+ /**
+ * @return the value to use in the {@code type} field of a Kubernetes event
+ */
+ public String value() {
+ return value;
+ }
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/ResourceEventRecorder.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/ResourceEventRecorder.java
new file mode 100644
index 0000000000..8cb887c977
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/ResourceEventRecorder.java
@@ -0,0 +1,35 @@
+/*
+ * 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.api.events;
+
+/**
+ * An {@link EventRecorder} bound to a single object, typically the primary resource of the current
+ * reconciliation.
+ *
+ *
Recording an event is best effort: failures to write the event to the cluster are logged and
+ * swallowed, and never fail the caller.
+ */
+public interface ResourceEventRecorder {
+
+ /** Records a {@link EventType#NORMAL} event about the bound object. */
+ void normal(String reason, String message);
+
+ /** Records a {@link EventType#WARNING} event about the bound object. */
+ void warn(String reason, String message);
+
+ /** Records the given event about the bound object. */
+ void record(EventRecord event);
+}
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 75d12eb1ad..9642f2b4ea 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
@@ -23,6 +23,7 @@
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
+import io.javaoperatorsdk.operator.api.events.ResourceEventRecorder;
import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ManagedWorkflowAndDependentResourceContext;
import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever;
import io.javaoperatorsdk.operator.processing.event.source.IndexerResourceCache;
@@ -213,6 +214,19 @@ default Optional getSecondaryResource(
ResourceOperations resourceOperations();
+ /**
+ * 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()}.
+ *
+ * @return an event recorder bound to the primary resource
+ * @throws UnsupportedOperationException if the implementation does not provide an event recorder
+ */
+ default ResourceEventRecorder eventRecorder() {
+ throw new UnsupportedOperationException(
+ "This implementation of Context does not provide a ResourceEventRecorder");
+ }
+
/** ExecutorService initialized by framework for workflows. Used for workflow standalone mode. */
ExecutorService getWorkflowExecutorService();
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 2d9a22b6fa..3ee5ae99c1 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
@@ -29,6 +29,7 @@
import io.fabric8.kubernetes.client.KubernetesClient;
import io.javaoperatorsdk.operator.ReconcilerUtilsInternal;
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
+import io.javaoperatorsdk.operator.api.events.ResourceEventRecorder;
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.DefaultManagedWorkflowAndDependentResourceContext;
import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ManagedWorkflowAndDependentResourceContext;
@@ -208,6 +209,11 @@ public ResourceOperations
resourceOperations() {
return resourceOperations;
}
+ @Override
+ public ResourceEventRecorder eventRecorder() {
+ return controller.eventRecorder().forResource(primaryResource);
+ }
+
@Override
public ExecutorService getWorkflowExecutorService() {
// note that this should be always received from executor service manager, so we are able to do
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 612c3d36f9..8ace94e9a5 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
@@ -38,7 +38,11 @@
import io.javaoperatorsdk.operator.RegisteredController;
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
import io.javaoperatorsdk.operator.api.config.ExecutorServiceManager;
+import io.javaoperatorsdk.operator.api.config.LeaderElectionConfiguration;
import io.javaoperatorsdk.operator.api.config.workflow.WorkflowSpec;
+import io.javaoperatorsdk.operator.api.events.DefaultEventRecorder;
+import io.javaoperatorsdk.operator.api.events.DefaultEventSink;
+import io.javaoperatorsdk.operator.api.events.EventRecorder;
import io.javaoperatorsdk.operator.api.monitoring.Metrics;
import io.javaoperatorsdk.operator.api.monitoring.Metrics.ControllerExecution;
import io.javaoperatorsdk.operator.api.reconciler.Cleaner;
@@ -96,6 +100,7 @@ public class Controller
private final EventProcessor
eventProcessor;
private final ControllerHealthInfo controllerHealthInfo;
private final EventSourceContext
eventSourceContext;
+ private final EventRecorder eventRecorder;
public Controller(
Reconciler
reconciler,
@@ -109,6 +114,16 @@ public Controller(
this.configuration = configuration;
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));
contextInitializer = reconciler instanceof ContextInitializer;
isCleaner = reconciler instanceof Cleaner;
@@ -343,6 +358,11 @@ public ControllerHealthInfo getControllerHealthInfo() {
return controllerHealthInfo;
}
+ @Override
+ public EventRecorder eventRecorder() {
+ return eventRecorder;
+ }
+
public KubernetesClient getClient() {
return kubernetesClient;
}
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/events/DefaultEventRecorderTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/events/DefaultEventRecorderTest.java
new file mode 100644
index 0000000000..c1571636ba
--- /dev/null
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/events/DefaultEventRecorderTest.java
@@ -0,0 +1,230 @@
+/*
+ * 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.api.events;
+
+import java.util.ArrayList;
+import java.util.List;
+
+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.Event;
+import io.fabric8.kubernetes.api.model.Namespace;
+import io.fabric8.kubernetes.api.model.NamespaceBuilder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+class DefaultEventRecorderTest {
+
+ private static final String CONTROLLER = "testcontroller";
+ private static final String INSTANCE = "operator-pod-1";
+
+ private final List emitted = new ArrayList<>();
+ private final DefaultEventRecorder recorder =
+ new DefaultEventRecorder(CONTROLLER, INSTANCE, emitted::add);
+
+ @Test
+ void fillsInEverythingDerivableFromTheControllerAndTheInvolvedObject() {
+ recorder.record(configMap(), EventRecord.warning("Failed", "could not do the thing"));
+
+ assertThat(emitted).hasSize(1);
+ var event = emitted.get(0);
+ assertThat(event.getType()).isEqualTo("Warning");
+ assertThat(event.getReason()).isEqualTo("Failed");
+ assertThat(event.getMessage()).isEqualTo("could not do the thing");
+ assertThat(event.getCount()).isEqualTo(1);
+ assertThat(event.getReportingComponent()).isEqualTo(CONTROLLER);
+ assertThat(event.getReportingInstance()).isEqualTo(INSTANCE);
+ assertThat(event.getSource().getComponent()).isEqualTo(CONTROLLER);
+ assertThat(event.getFirstTimestamp()).isNotNull().isEqualTo(event.getLastTimestamp());
+
+ var involved = event.getInvolvedObject();
+ assertThat(involved.getKind()).isEqualTo("ConfigMap");
+ assertThat(involved.getApiVersion()).isEqualTo("v1");
+ assertThat(involved.getName()).isEqualTo("test1");
+ assertThat(involved.getNamespace()).isEqualTo("ns1");
+ assertThat(involved.getUid()).isEqualTo("uid-1");
+ assertThat(involved.getResourceVersion()).isEqualTo("42");
+ }
+
+ @Test
+ void createsTheEventInTheNamespaceOfTheInvolvedObject() {
+ recorder.record(configMap(), EventRecord.normal("Created", "created"));
+
+ assertThat(emitted.get(0).getMetadata().getNamespace()).isEqualTo("ns1");
+ assertThat(emitted.get(0).getMetadata().getName()).startsWith("test1.");
+ }
+
+ @Test
+ void recordsEventsForClusterScopedObjectsInTheDefaultNamespace() {
+ recorder.record(clusterScoped(), EventRecord.normal("Created", "created"));
+
+ assertThat(emitted.get(0).getMetadata().getNamespace())
+ .isEqualTo(DefaultEventRecorder.CLUSTER_SCOPED_EVENT_NAMESPACE);
+ assertThat(emitted.get(0).getInvolvedObject().getNamespace()).isNull();
+ }
+
+ @Test
+ void clusterScopedEventNamespaceCanBeOverridden() {
+ var configured = new DefaultEventRecorder(CONTROLLER, INSTANCE, "operator-ns", emitted::add);
+
+ configured.record(clusterScoped(), EventRecord.normal("Created", "created"));
+
+ assertThat(emitted.get(0).getMetadata().getNamespace()).isEqualTo("operator-ns");
+ }
+
+ @Test
+ void anOverriddenClusterScopedNamespaceDoesNotAffectNamespacedResources() {
+ var configured = new DefaultEventRecorder(CONTROLLER, INSTANCE, "operator-ns", emitted::add);
+
+ configured.record(configMap(), EventRecord.normal("Created", "created"));
+
+ assertThat(emitted.get(0).getMetadata().getNamespace()).isEqualTo("ns1");
+ }
+
+ @Test
+ void perEventReportingComponentOverridesTheControllerName() {
+ recorder.record(
+ configMap(),
+ EventRecord.builder()
+ .reason("Submitted")
+ .message("submitted")
+ .reportingComponent("JobManagerDeployment")
+ .action("Submit")
+ .build());
+
+ assertThat(emitted.get(0).getReportingComponent()).isEqualTo("JobManagerDeployment");
+ assertThat(emitted.get(0).getSource().getComponent()).isEqualTo("JobManagerDeployment");
+ assertThat(emitted.get(0).getAction()).isEqualTo("Submit");
+ // the reporting instance is never overridable per event
+ assertThat(emitted.get(0).getReportingInstance()).isEqualTo(INSTANCE);
+ }
+
+ @Test
+ void passesLabelsAndAnnotationsThrough() {
+ recorder.record(
+ configMap(),
+ EventRecord.builder()
+ .reason("Scaling")
+ .message("scaling up")
+ .label("group", "autoscaler")
+ .annotation("recommendation", "4")
+ .build());
+
+ assertThat(emitted.get(0).getMetadata().getLabels()).containsEntry("group", "autoscaler");
+ assertThat(emitted.get(0).getMetadata().getAnnotations()).containsEntry("recommendation", "4");
+ }
+
+ @Test
+ void aFailingSinkNeverFailsTheCaller() {
+ var failing =
+ new DefaultEventRecorder(
+ CONTROLLER,
+ INSTANCE,
+ event -> {
+ throw new RuntimeException("API server said no");
+ });
+
+ assertThatCode(() -> failing.record(configMap(), EventRecord.normal("Created", "created")))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void boundRecorderRecordsAboutTheBoundObject() {
+ var bound = recorder.forResource(configMap());
+
+ bound.normal("Created", "created");
+ bound.warn("Failed", "failed");
+
+ assertThat(emitted).hasSize(2);
+ assertThat(emitted)
+ .allSatisfy(e -> assertThat(e.getInvolvedObject().getName()).isEqualTo("test1"));
+ assertThat(emitted.get(0).getType()).isEqualTo("Normal");
+ assertThat(emitted.get(1).getType()).isEqualTo("Warning");
+ }
+
+ @Test
+ void truncatesTheNameOfTheInvolvedObjectToStayWithinTheKubernetesNameLimit() {
+ var longName = "a".repeat(253);
+ var configMap =
+ new ConfigMapBuilder()
+ .withNewMetadata()
+ .withName(longName)
+ .withNamespace("ns1")
+ .endMetadata()
+ .build();
+
+ recorder.record(configMap, EventRecord.normal("Created", "created"));
+
+ var name = emitted.get(0).getMetadata().getName();
+ assertThat(name).hasSizeLessThanOrEqualTo(253);
+ assertThat(name).startsWith("a");
+ // the involved object itself keeps its full name, only the event name is shortened
+ assertThat(emitted.get(0).getInvolvedObject().getName()).isEqualTo(longName);
+ }
+
+ @Test
+ void reasonIsRequired() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> EventRecord.builder().message("no reason given").build());
+ }
+
+ @Test
+ void namesEventsWithADnsSafeHashSuffix() {
+ recorder.record(configMap(), EventRecord.normal("Created", "created"));
+
+ assertThat(emitted.get(0).getMetadata().getName()).matches("test1\\.[0-9a-f]{32}");
+ }
+
+ @Test
+ void givesEventsWhoseMessagesCollideUnderStringHashCodeDistinctNames() {
+ // "Aa" and "BB" share a String.hashCode(), and so do the two identities they are part of: the
+ // message comes last and both are of the same length, so the collision survives the common
+ // prefix. Were the name suffix derived from that hash, the two events would resolve to one
+ // name and the sink would take the second for a repeat of the first and drop it.
+ assertThat("Aa".hashCode()).isEqualTo("BB".hashCode());
+
+ recorder.record(configMap(), EventRecord.warning("Failed", "Aa"));
+ recorder.record(configMap(), EventRecord.warning("Failed", "BB"));
+
+ assertThat(emitted).hasSize(2);
+ assertThat(emitted.get(0).getMetadata().getName())
+ .isNotEqualTo(emitted.get(1).getMetadata().getName());
+ }
+
+ ConfigMap configMap() {
+ return new ConfigMapBuilder()
+ .withNewMetadata()
+ .withName("test1")
+ .withNamespace("ns1")
+ .withUid("uid-1")
+ .withResourceVersion("42")
+ .endMetadata()
+ .build();
+ }
+
+ Namespace clusterScoped() {
+ return new NamespaceBuilder()
+ .withNewMetadata()
+ .withName("ns1")
+ .withUid("uid-2")
+ .endMetadata()
+ .build();
+ }
+}
diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java
index a5b798190f..c8daf89724 100644
--- a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java
+++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java
@@ -101,7 +101,11 @@ public static ConfigLoader getDefault() {
new ConfigBinding<>(
"clone-secondary-resources-when-getting-from-cache",
Boolean.class,
- ConfigurationServiceOverrider::withCloneSecondaryResourcesWhenGettingFromCache));
+ ConfigurationServiceOverrider::withCloneSecondaryResourcesWhenGettingFromCache),
+ new ConfigBinding<>(
+ "events.cluster-scoped-namespace",
+ String.class,
+ ConfigurationServiceOverrider::withClusterScopedEventNamespace));
// ---------------------------------------------------------------------------
// Operator-level leader-election property keys
diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorder/EventRecorderCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorder/EventRecorderCustomResource.java
new file mode 100644
index 0000000000..4ab139de38
--- /dev/null
+++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorder/EventRecorderCustomResource.java
@@ -0,0 +1,29 @@
+/*
+ * 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.eventrecorder;
+
+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.Kind;
+import io.fabric8.kubernetes.model.annotation.ShortNames;
+import io.fabric8.kubernetes.model.annotation.Version;
+
+@Group("sample.javaoperatorsdk")
+@Version("v1")
+@Kind("EventRecorderCustomResource")
+@ShortNames("erc")
+public class EventRecorderCustomResource extends CustomResource implements Namespaced {}
diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorder/EventRecorderIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorder/EventRecorderIT.java
new file mode 100644
index 0000000000..e23468812e
--- /dev/null
+++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorder/EventRecorderIT.java
@@ -0,0 +1,93 @@
+/*
+ * 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.eventrecorder;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import io.fabric8.kubernetes.api.model.Event;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+import io.javaoperatorsdk.annotation.Sample;
+import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+@Sample(
+ tldr = "Recording Kubernetes events from a reconciler",
+ description =
+ """
+ Demonstrates recording Kubernetes events about the primary resource using the event \
+ recorder available from the reconciliation context. Verifies that both normal and warning \
+ events reach the cluster, refer to the primary resource as their involved object, and are \
+ attributed to the reporting controller.
+ """)
+class EventRecorderIT {
+
+ public static final String TEST_RESOURCE_NAME = "test1";
+
+ @RegisterExtension
+ LocallyRunOperatorExtension extension =
+ LocallyRunOperatorExtension.builder().withReconciler(new EventRecorderReconciler()).build();
+
+ @Test
+ void recordsEventsAboutThePrimaryResource() {
+ extension.create(testResource());
+
+ await()
+ .untilAsserted(
+ () -> {
+ var events = eventsForTestResource();
+ assertThat(events)
+ .extracting(Event::getReason)
+ .contains(
+ EventRecorderReconciler.NORMAL_REASON,
+ EventRecorderReconciler.WARNING_REASON);
+
+ var warning =
+ events.stream()
+ .filter(e -> EventRecorderReconciler.WARNING_REASON.equals(e.getReason()))
+ .findFirst()
+ .orElseThrow();
+ assertThat(warning.getType()).isEqualTo("Warning");
+ assertThat(warning.getMessage()).isEqualTo("this is a warning about the resource");
+ assertThat(warning.getInvolvedObject().getKind())
+ .isEqualTo("EventRecorderCustomResource");
+ assertThat(warning.getInvolvedObject().getName()).isEqualTo(TEST_RESOURCE_NAME);
+ assertThat(warning.getReportingComponent()).isNotBlank();
+ assertThat(warning.getReportingInstance()).isNotBlank();
+ });
+ }
+
+ private List eventsForTestResource() {
+ return extension
+ .getKubernetesClient()
+ .v1()
+ .events()
+ .inNamespace(extension.getNamespace())
+ .withField("involvedObject.name", TEST_RESOURCE_NAME)
+ .list()
+ .getItems();
+ }
+
+ EventRecorderCustomResource testResource() {
+ var resource = new EventRecorderCustomResource();
+ resource.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build());
+ return resource;
+ }
+}
diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorder/EventRecorderReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorder/EventRecorderReconciler.java
new file mode 100644
index 0000000000..8e07719768
--- /dev/null
+++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorder/EventRecorderReconciler.java
@@ -0,0 +1,34 @@
+/*
+ * 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.eventrecorder;
+
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
+import io.javaoperatorsdk.operator.api.reconciler.UpdateControl;
+
+public class EventRecorderReconciler implements Reconciler {
+
+ public static final String NORMAL_REASON = "Reconciled";
+ public static final String WARNING_REASON = "SomethingIsOff";
+
+ @Override
+ public UpdateControl reconcile(
+ EventRecorderCustomResource resource, Context context) {
+ context.eventRecorder().normal(NORMAL_REASON, "resource reconciled");
+ context.eventRecorder().warn(WARNING_REASON, "this is a warning about the resource");
+ return UpdateControl.noUpdate();
+ }
+}