diff --git a/docs/content/en/docs/documentation/operations/configuration.md b/docs/content/en/docs/documentation/operations/configuration.md
index 513cc432d8..1d74cf3f0f 100644
--- a/docs/content/en/docs/documentation/operations/configuration.md
+++ b/docs/content/en/docs/documentation/operations/configuration.md
@@ -294,6 +294,26 @@ 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`) |
+
+Recording events requires the operator's service account to be allowed `get`, `create` and `patch` on
+`events` in the core (`""`) API group. All three are used: an event that does not exist yet is
+created, and an event that does is a repeat of one already recorded, which is patched to count the
+new occurrence instead of being recorded a second time. The
+[generic Helm chart](helm-chart.md) grants all three. If you write your own RBAC and a permission is
+missing, recording fails silently as far as reconciliation is concerned: the failure is swallowed so
+that it cannot break a reconciliation, and only shows up as a warning in the operator log.
+
+Note that events live in a namespace of their own choosing, which is the namespace of the object
+they are about, and for cluster scoped objects the namespace configured above. A namespaced `Role`
+therefore has to grant the permission in every namespace events are recorded in — including
+`default`, or whatever `josdk.events.cluster-scoped-namespace` is set to, if the operator reconciles
+cluster scoped resources. A `ClusterRole` covers all of them at once.
+
#### Leader Election
Leader election is activated when at least one `josdk.leader-election.*` key is present.
diff --git a/docs/content/en/docs/documentation/operations/helm-chart.md b/docs/content/en/docs/documentation/operations/helm-chart.md
index a0901f31f7..92e11dfee6 100644
--- a/docs/content/en/docs/documentation/operations/helm-chart.md
+++ b/docs/content/en/docs/documentation/operations/helm-chart.md
@@ -49,6 +49,12 @@ secondaryResources:
Primary resources get read/watch/patch permissions and status sub-resource access.
Secondary resources get full CRUD permissions. Default verbs can be overridden per resource entry.
+The ClusterRole always grants `get`, `create` and `patch` on `events` in the core API group, so
+recording events works without any extra configuration. All three are needed: an event is looked up
+by name before it is created, so that recording the same event again resolves to the event already
+recorded, and that event is then patched to count the new occurrence rather than recorded again. See
+[Events](configuration.md#events) for what this means if you write your own RBAC.
+
### Operator Environment
The chart injects `OPERATOR_NAMESPACE` automatically. You can optionally set `WATCH_NAMESPACE` to
diff --git a/helm/generic-helm-chart/templates/clusterrole.yaml b/helm/generic-helm-chart/templates/clusterrole.yaml
index 04070da489..f189e93115 100644
--- a/helm/generic-helm-chart/templates/clusterrole.yaml
+++ b/helm/generic-helm-chart/templates/clusterrole.yaml
@@ -71,11 +71,15 @@ rules:
{{- end }}
{{- end }}
# Event permissions - for recording events
+# All three verbs are used: an event is looked up by name ("get") so that a repeat of it resolves to
+# the event already recorded, which is then "patch"ed to count the new occurrence instead of being
+# "create"d a second time. Without them recording fails, visible only as a warning in the log.
- apiGroups:
- ""
resources:
- events
verbs:
+ - get
- create
- patch
{{- /* Leader election - Lease permissions */}}
diff --git a/helm/generic-helm-chart/tests/clusterrole_test.yaml b/helm/generic-helm-chart/tests/clusterrole_test.yaml
index 7cfdf4dc99..86e0aa2589 100644
--- a/helm/generic-helm-chart/tests/clusterrole_test.yaml
+++ b/helm/generic-helm-chart/tests/clusterrole_test.yaml
@@ -44,6 +44,7 @@ tests:
resources:
- events
verbs:
+ - get
- create
- patch
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..e6aa6cbce6 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.event.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..c3d1636983 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.event.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/event/DefaultEventRecorder.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/DefaultEventRecorder.java
new file mode 100644
index 0000000000..abf4ab025f
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/DefaultEventRecorder.java
@@ -0,0 +1,255 @@
+/*
+ * 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.event;
+
+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. Repeat occurrences are then counted on that event rather than recorded as copies
+ * of it, 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