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
index 2e7023623d..b03701be8c 100644
--- 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
@@ -48,10 +48,11 @@
* 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}.
+ *
By default, 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}. How events aggregate, how they are named
+ * and whether they carry an owner reference can be configured, see {@link #builder(EventSink)}.
*/
public class DefaultEventRecorder implements EventRecorder {
@@ -74,9 +75,74 @@ public class DefaultEventRecorder implements EventRecorder {
private static final int IDENTITY_HASH_LENGTH = 32;
private final EventSink sink;
+ private final EventNamingStrategy namingStrategy;
+ private final EventKeyStrategy keyStrategy;
+ private final boolean ownerReference;
public DefaultEventRecorder(EventSink sink) {
+ this(sink, EventNamingStrategy.none(), EventKeyStrategy.none(), false);
+ }
+
+ private DefaultEventRecorder(
+ EventSink sink,
+ EventNamingStrategy namingStrategy,
+ EventKeyStrategy keyStrategy,
+ boolean ownerReference) {
this.sink = sink;
+ this.namingStrategy = namingStrategy;
+ this.keyStrategy = keyStrategy;
+ this.ownerReference = ownerReference;
+ }
+
+ public static Builder builder(EventSink sink) {
+ return new Builder(sink);
+ }
+
+ /** Builder for {@link DefaultEventRecorder}. */
+ public static final class Builder {
+
+ private final EventSink sink;
+ private EventNamingStrategy namingStrategy = EventNamingStrategy.none();
+ private EventKeyStrategy keyStrategy = EventKeyStrategy.none();
+ private boolean ownerReference = false;
+
+ private Builder(EventSink sink) {
+ this.sink = Objects.requireNonNull(sink, "sink must not be null");
+ }
+
+ /** The strategy naming recorded events, see {@link EventNamingStrategy}. */
+ public Builder namingStrategy(EventNamingStrategy namingStrategy) {
+ this.namingStrategy =
+ Objects.requireNonNull(namingStrategy, "namingStrategy must not be null");
+ return this;
+ }
+
+ /**
+ * The strategy deriving the default aggregation key of records that do not set one, see {@link
+ * EventKeyStrategy}. The key is ignored for events whose name is set by the record or resolved
+ * by the naming strategy, see {@link EventNamingStrategy}.
+ */
+ public Builder keyStrategy(EventKeyStrategy keyStrategy) {
+ this.keyStrategy = Objects.requireNonNull(keyStrategy, "keyStrategy must not be null");
+ return this;
+ }
+
+ /**
+ * When set, recorded events carry an {@code ownerReference} to the object they are about. The
+ * reference expresses ownership for tooling that reads it; note that the Kubernetes garbage
+ * collector ignores events, so it does not cause cascade deletion, events expire through the
+ * event TTL either way. Records can override this per event via {@link
+ * EventRecord.Builder#ownedByRegarding(boolean)}. The reference is only set when the object
+ * already has a uid.
+ */
+ public Builder ownerReference(boolean ownerReference) {
+ this.ownerReference = ownerReference;
+ return this;
+ }
+
+ public DefaultEventRecorder build() {
+ return new DefaultEventRecorder(sink, namingStrategy, keyStrategy, ownerReference);
+ }
}
/**
@@ -111,14 +177,17 @@ private static String resolve() {
public void record(EventRecord event, Context> context) {
Objects.requireNonNull(context, "the context of the reconciliation must not be null");
Objects.requireNonNull(event, "event must not be null");
+ Event assembled = null;
try {
- sink.emit(toEvent(context, event), context);
+ assembled = toEvent(context, event);
+ sink.emit(assembled, context);
} 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 {}",
+ "Could not record {} event named {} with reason {} for resource {} in namespace {}",
event.type(),
+ assembled != null ? assembled.getMetadata().getName() : "unknown",
event.reason(),
context.getPrimaryResource().getMetadata().getName(),
context.getPrimaryResource().getMetadata().getNamespace(),
@@ -164,6 +233,23 @@ protected Event toEvent(Context> context, EventRecord record) {
.withNewSource()
.withComponent(record.reportingComponent().orElse(controllerName))
.endSource();
+ boolean ownedByRegarding = record.ownedByRegarding().orElse(ownerReference);
+ if (ownedByRegarding && regarding.getMetadata().getUid() == null) {
+ log.debug(
+ "Not setting the owner reference on the event about {}: the object has no uid yet",
+ regarding.getMetadata().getName());
+ }
+ if (ownedByRegarding && regarding.getMetadata().getUid() != null) {
+ builder
+ .editMetadata()
+ .addNewOwnerReference()
+ .withApiVersion(regarding.getApiVersion())
+ .withKind(regarding.getKind())
+ .withName(regarding.getMetadata().getName())
+ .withUid(regarding.getMetadata().getUid())
+ .endOwnerReference()
+ .endMetadata();
+ }
record.action().ifPresent(builder::withAction);
return builder.build();
}
@@ -181,17 +267,47 @@ private String eventNamespace(HasMetadata regarding, Context> context) {
CLUSTER_SCOPED_EVENT_NAMESPACE);
}
+ private String eventName(HasMetadata regarding, EventRecord record, String reportingController) {
+ return record
+ .name()
+ .filter(name -> !name.isBlank())
+ .or(() -> namingStrategy.nameFor(regarding, record).filter(name -> !name.isBlank()))
+ .map(DefaultEventRecorder::truncateToMaxNameLength)
+ .filter(name -> !name.isBlank())
+ .orElseGet(() -> identityHashName(regarding, record, reportingController));
+ }
+
+ private static String truncateToMaxNameLength(String name) {
+ if (name.length() <= MAX_NAME_LENGTH) {
+ return name;
+ }
+ var truncated = name.substring(0, MAX_NAME_LENGTH);
+ while (truncated.endsWith("-") || truncated.endsWith(".")) {
+ truncated = truncated.substring(0, truncated.length() - 1);
+ }
+ log.warn(
+ "Truncated the name of event {} to {} to stay within the Kubernetes name limit",
+ name,
+ truncated);
+ return truncated;
+ }
+
/**
* 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.
+ * reporting component and, unless the record sets a {@link EventRecord#key()} or the recorder is
+ * built with a default {@link EventKeyStrategy}, 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.
+ *
+ *
This is the fallback when the record does not set a name and the naming strategy resolves to
+ * nothing, see {@link EventNamingStrategy}.
*/
- private String eventName(HasMetadata regarding, EventRecord record, String reportingController) {
+ private String identityHashName(
+ HasMetadata regarding, EventRecord record, String reportingController) {
var metadata = regarding.getMetadata();
var identity =
String.join(
@@ -201,7 +317,10 @@ private String eventName(HasMetadata regarding, EventRecord record, String repor
record.type().value(),
record.reason(),
record.reportingComponent().orElse(reportingController),
- record.key().orElseGet(() -> requireNonNullElse(record.message(), "")));
+ record
+ .key()
+ .or(() -> keyStrategy.keyFor(regarding, record))
+ .orElseGet(() -> requireNonNullElse(record.message(), "")));
var suffix = "." + identityDigest(identity);
var prefix = metadata.getName();
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/EventKeyStrategy.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/EventKeyStrategy.java
new file mode 100644
index 0000000000..632ff6ea6a
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/EventKeyStrategy.java
@@ -0,0 +1,55 @@
+/*
+ * 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.util.Optional;
+
+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;
+
+/**
+ * Derives the default aggregation key of an event, used when the {@link EventRecord} does not set
+ * one explicitly. The key identifies an event among the events about the same object, so that
+ * repeated occurrences resolve to the same event rather than to one event each, see {@link
+ * EventRecord#key()}.
+ *
+ *
An empty result leaves the record without a default key, which keeps the message part of the
+ * event identity.
+ *
+ *
Implementations are called from concurrent reconciliations and must be thread safe.
+ */
+@Experimental(API_MIGHT_CHANGE)
+@FunctionalInterface
+public interface EventKeyStrategy {
+
+ Optional keyFor(HasMetadata regarding, EventRecord record);
+
+ /** No default key: the message stays part of the event identity. */
+ static EventKeyStrategy none() {
+ return (regarding, record) -> Optional.empty();
+ }
+
+ /**
+ * Aggregates by event type and reason: all occurrences of a reason resolve to one event whose
+ * count grows and whose message is replaced with the latest one. The right choice for events that
+ * report a current state rather than individual occurrences.
+ */
+ static EventKeyStrategy byReason() {
+ return (regarding, record) -> Optional.of(record.type().value() + "/" + record.reason());
+ }
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/EventNamingStrategy.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/EventNamingStrategy.java
new file mode 100644
index 0000000000..3ffeb8b678
--- /dev/null
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/EventNamingStrategy.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.event;
+
+import java.util.Optional;
+
+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;
+
+/**
+ * Names the event recorded about an object. The name is what the sink looks a recorded event up by,
+ * so it is also the aggregation identity: two records resolving to the same name are counted as
+ * occurrences of one event. A name must therefore be unique among the events it should not
+ * aggregate with, and stable across operator restarts and replicas.
+ *
+ * A name must be a valid RFC 1123 DNS subdomain: at most 253 lowercase alphanumeric characters,
+ * {@code -} or {@code .}, starting and ending with an alphanumeric character. The API server
+ * rejects other names, and since recording is best effort, an event with an invalid name is
+ * dropped. Names longer than the limit are truncated. A name derived from the object and a fixed
+ * lowercase suffix (such as {@code -status-report}) satisfies all of this by construction.
+ *
+ * An empty result or a blank name falls back to the default {@code .}
+ * name.
+ *
+ * Implementations are called from concurrent reconciliations and must be thread safe.
+ */
+@Experimental(API_MIGHT_CHANGE)
+@FunctionalInterface
+public interface EventNamingStrategy {
+
+ Optional nameFor(HasMetadata regarding, EventRecord record);
+
+ /** No custom naming: every event gets the default {@code .} name. */
+ static EventNamingStrategy none() {
+ return (regarding, record) -> Optional.empty();
+ }
+}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/EventRecord.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/EventRecord.java
index e7b736abc0..5fdaa648e7 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/EventRecord.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/EventRecord.java
@@ -32,7 +32,9 @@ public final class EventRecord {
private final EventType type;
private final String reason;
private final String message;
+ private final String name;
private final String key;
+ private final Boolean ownedByRegarding;
private final String action;
private final String reportingComponent;
private final Map labels;
@@ -42,11 +44,13 @@ private EventRecord(Builder builder) {
this.type = builder.type;
this.reason = builder.reason;
this.message = builder.message;
+ this.name = builder.name;
this.key = builder.key;
this.action = builder.action;
this.reportingComponent = builder.reportingComponent;
this.labels = Map.copyOf(builder.labels);
this.annotations = Map.copyOf(builder.annotations);
+ this.ownedByRegarding = builder.ownedByRegarding;
}
public static Builder builder() {
@@ -75,14 +79,34 @@ public String message() {
return message;
}
+ /**
+ * The name of the recorded event, overriding the recorder's naming. The name is the aggregation
+ * identity and must be a valid RFC 1123 DNS subdomain, see {@link EventNamingStrategy}. A blank
+ * name is treated as unset.
+ */
+ public Optional name() {
+ return Optional.ofNullable(name);
+ }
+
/**
* 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.
+ *
+ * The key is ignored when the record sets a {@link #name()} or the recorder's naming strategy
+ * resolves one: the name is then the aggregation identity on its own.
*/
public Optional key() {
return Optional.ofNullable(key);
}
+ /**
+ * Whether the recorded event carries an {@code ownerReference} to the object it is about. When
+ * empty, the recorder's own setting applies.
+ */
+ public Optional ownedByRegarding() {
+ return Optional.ofNullable(ownedByRegarding);
+ }
+
/**
* 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.
@@ -119,7 +143,9 @@ public static final class Builder {
private EventType type = EventType.NORMAL;
private String reason;
private String message;
+ private String name;
private String key;
+ private Boolean ownedByRegarding;
private String action;
private String reportingComponent;
private final Map labels = new HashMap<>();
@@ -142,12 +168,27 @@ public Builder message(String message) {
return this;
}
+ /** Sets the name of the recorded event, see {@link EventRecord#name()}. */
+ public Builder name(String name) {
+ this.name = name;
+ return this;
+ }
+
/** Sets the key identifying this event, see {@link EventRecord#key()}. */
public Builder key(String key) {
this.key = key;
return this;
}
+ /**
+ * Sets whether this event is owned by the object it is about, see {@link
+ * EventRecord#ownedByRegarding()}.
+ */
+ public Builder ownedByRegarding(boolean ownedByRegarding) {
+ this.ownedByRegarding = ownedByRegarding;
+ return this;
+ }
+
public Builder action(String action) {
this.action = action;
return this;
diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/event/DefaultEventRecorderTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/event/DefaultEventRecorderTest.java
index eb713e5d74..09db903658 100644
--- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/event/DefaultEventRecorderTest.java
+++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/event/DefaultEventRecorderTest.java
@@ -226,6 +226,14 @@ void reasonIsRequired() {
.isThrownBy(() -> EventRecord.builder().message("no reason given").build());
}
+ @Test
+ void alwaysDerivesTheSameDefaultNameForTheSameEvent() {
+ recorder.record(EventRecord.normal("Created", "created"), context(configMap()));
+
+ assertThat(emitted.get(0).getMetadata().getName())
+ .isEqualTo("test1.3c699548f37ff9cd6d2a786f64a27228");
+ }
+
@Test
void namesEventsWithADnsSafeHashSuffix() {
recorder.record(EventRecord.normal("Created", "created"), context(configMap()));
@@ -249,6 +257,239 @@ void givesEventsWhoseMessagesCollideUnderStringHashCodeDistinctNames() {
.isNotEqualTo(emitted.get(1).getMetadata().getName());
}
+ @Test
+ void takesTheMessageOutOfTheEventIdentityWithADefaultKeyStrategy() {
+ var recorder =
+ DefaultEventRecorder.builder((event, context) -> emitted.add(event))
+ .keyStrategy(EventKeyStrategy.byReason())
+ .build();
+ var context = context(configMap());
+
+ recorder.record(EventRecord.warning("Failed", "first message"), context);
+ recorder.record(EventRecord.warning("Failed", "second message"), context);
+
+ assertThat(emitted.get(0).getMetadata().getName())
+ .isEqualTo(emitted.get(1).getMetadata().getName());
+ }
+
+ @Test
+ void prefersThePerRecordKeyOverTheDefaultKeyStrategy() {
+ var recorder =
+ DefaultEventRecorder.builder((event, context) -> emitted.add(event))
+ .keyStrategy(EventKeyStrategy.byReason())
+ .build();
+ var context = context(configMap());
+
+ recorder.record(EventRecord.warning("Failed", "message"), context);
+ recorder.record(
+ EventRecord.builder()
+ .type(EventType.WARNING)
+ .reason("Failed")
+ .message("message")
+ .key("another aggregate")
+ .build(),
+ context);
+
+ assertThat(emitted.get(0).getMetadata().getName())
+ .isNotEqualTo(emitted.get(1).getMetadata().getName());
+ }
+
+ @Test
+ void keepsTheMessageInTheEventIdentityWithoutADefaultKeyStrategy() {
+ var context = context(configMap());
+
+ recorder.record(EventRecord.warning("Failed", "first message"), context);
+ recorder.record(EventRecord.warning("Failed", "second message"), context);
+
+ assertThat(emitted.get(0).getMetadata().getName())
+ .isNotEqualTo(emitted.get(1).getMetadata().getName());
+ }
+
+ @Test
+ void keepsEventsWithTheSameReasonButDifferentTypesApartUnderByReason() {
+ var recorder =
+ DefaultEventRecorder.builder((event, context) -> emitted.add(event))
+ .keyStrategy(EventKeyStrategy.byReason())
+ .build();
+ var context = context(configMap());
+
+ recorder.record(EventRecord.normal("Flipped", "message"), context);
+ recorder.record(EventRecord.warning("Flipped", "message"), context);
+
+ assertThat(emitted.get(0).getMetadata().getName())
+ .isNotEqualTo(emitted.get(1).getMetadata().getName());
+ }
+
+ @Test
+ void setsTheOwnerReferenceToTheInvolvedObjectWhenOwningEventsByRegarding() {
+ var recorder =
+ DefaultEventRecorder.builder((event, context) -> emitted.add(event))
+ .ownerReference(true)
+ .build();
+
+ recorder.record(EventRecord.normal("Created", "created"), context(configMap()));
+
+ assertThat(emitted.get(0).getMetadata().getOwnerReferences())
+ .singleElement()
+ .satisfies(
+ owner -> {
+ assertThat(owner.getApiVersion()).isEqualTo("v1");
+ assertThat(owner.getKind()).isEqualTo("ConfigMap");
+ assertThat(owner.getName()).isEqualTo("test1");
+ assertThat(owner.getUid()).isEqualTo("uid-1");
+ });
+ }
+
+ @Test
+ void carriesNoOwnerReferenceByDefault() {
+ recorder.record(EventRecord.normal("Created", "created"), context(configMap()));
+
+ assertThat(emitted.get(0).getMetadata().getOwnerReferences()).isEmpty();
+ }
+
+ @Test
+ void letsARecordOptOutOfTheRecorderLevelOwnerReference() {
+ var recorder =
+ DefaultEventRecorder.builder((event, context) -> emitted.add(event))
+ .ownerReference(true)
+ .build();
+
+ recorder.record(
+ EventRecord.builder().reason("Created").message("created").ownedByRegarding(false).build(),
+ context(configMap()));
+
+ assertThat(emitted.get(0).getMetadata().getOwnerReferences()).isEmpty();
+ }
+
+ @Test
+ void letsARecordOptIntoTheOwnerReferenceOnItsOwn() {
+ recorder.record(
+ EventRecord.builder().reason("Created").message("created").ownedByRegarding(true).build(),
+ context(configMap()));
+
+ assertThat(emitted.get(0).getMetadata().getOwnerReferences()).hasSize(1);
+ }
+
+ @Test
+ void setsNoOwnerReferenceWhenTheRegardingObjectHasNoUidYet() {
+ var withoutUid = configMap();
+ withoutUid.getMetadata().setUid(null);
+
+ recorder.record(
+ EventRecord.builder().reason("Created").message("created").ownedByRegarding(true).build(),
+ context(withoutUid));
+
+ assertThat(emitted.get(0).getMetadata().getOwnerReferences()).isEmpty();
+ }
+
+ @Test
+ void namesTheEventAfterThePerRecordNameWhenOneIsSet() {
+ recorder.record(
+ EventRecord.builder().reason("Created").message("created").name("my-event-name").build(),
+ context(configMap()));
+
+ assertThat(emitted.get(0).getMetadata().getName()).isEqualTo("my-event-name");
+ }
+
+ @Test
+ void namesEventsThroughTheNamingStrategy() {
+ var recorder =
+ DefaultEventRecorder.builder((event, context) -> emitted.add(event))
+ .namingStrategy(
+ (regarding, record) ->
+ Optional.of(regarding.getMetadata().getName() + "-" + record.reason()))
+ .build();
+
+ recorder.record(EventRecord.warning("failed", "first"), context(configMap()));
+ recorder.record(EventRecord.warning("failed", "second"), context(configMap()));
+
+ assertThat(emitted)
+ .allSatisfy(event -> assertThat(event.getMetadata().getName()).isEqualTo("test1-failed"));
+ }
+
+ @Test
+ void fallsBackToTheIdentityHashNameWhenTheNamingStrategyResolvesNothing() {
+ var recorder =
+ DefaultEventRecorder.builder((event, context) -> emitted.add(event))
+ .namingStrategy((regarding, record) -> Optional.empty())
+ .build();
+
+ recorder.record(EventRecord.warning("Failed", "message"), context(configMap()));
+
+ assertThat(emitted.get(0).getMetadata().getName())
+ .startsWith("test1.")
+ .hasSize("test1.".length() + 32);
+ }
+
+ @Test
+ void truncatesSuppliedNamesToTheKubernetesNameLengthLimit() {
+ recorder.record(
+ EventRecord.builder().reason("Created").message("created").name("a".repeat(300)).build(),
+ context(configMap()));
+
+ assertThat(emitted.get(0).getMetadata().getName()).hasSize(253);
+ }
+
+ @Test
+ void stripsTrailingSeparatorsFromTruncatedNames() {
+ recorder.record(
+ EventRecord.builder()
+ .reason("Created")
+ .message("created")
+ .name("a".repeat(252) + "." + "b".repeat(47))
+ .build(),
+ context(configMap()));
+ recorder.record(
+ EventRecord.builder()
+ .reason("Created")
+ .message("created")
+ .name("b".repeat(252) + "-" + "c".repeat(47))
+ .build(),
+ context(configMap()));
+
+ assertThat(emitted.get(0).getMetadata().getName()).isEqualTo("a".repeat(252));
+ assertThat(emitted.get(1).getMetadata().getName()).isEqualTo("b".repeat(252));
+ }
+
+ @Test
+ void treatsABlankNameAsUnset() {
+ var recorder =
+ DefaultEventRecorder.builder((event, context) -> emitted.add(event))
+ .namingStrategy((regarding, record) -> Optional.of(" "))
+ .build();
+
+ recorder.record(
+ EventRecord.builder().reason("Created").message("created").name("").build(),
+ context(configMap()));
+
+ assertThat(emitted.get(0).getMetadata().getName()).matches("test1\\.[0-9a-f]{32}");
+ }
+
+ @Test
+ void fallsBackToTheIdentityHashNameWhenTruncationLeavesNothing() {
+ recorder.record(
+ EventRecord.builder().reason("Created").message("created").name(".".repeat(300)).build(),
+ context(configMap()));
+
+ assertThat(emitted.get(0).getMetadata().getName()).matches("test1\\.[0-9a-f]{32}");
+ }
+
+ @Test
+ void aFailingNamingStrategyNeverFailsTheCaller() {
+ var recorder =
+ DefaultEventRecorder.builder((event, context) -> emitted.add(event))
+ .namingStrategy(
+ (regarding, record) -> {
+ throw new RuntimeException("cannot derive a name");
+ })
+ .build();
+
+ assertThatCode(
+ () -> recorder.record(EventRecord.normal("Created", "created"), context(configMap())))
+ .doesNotThrowAnyException();
+ assertThat(emitted).isEmpty();
+ }
+
Context> context(HasMetadata primaryResource) {
return context(primaryResource, DefaultEventRecorder.CLUSTER_SCOPED_EVENT_NAMESPACE);
}
@@ -259,7 +500,7 @@ Context> context(HasMetadata primaryResource) {
* the configuration service the reporting instance and the cluster scoped event namespace come
* from.
*/
- @SuppressWarnings({"unchecked", "rawtypes"})
+ @SuppressWarnings("rawtypes")
Context> context(HasMetadata primaryResource, String clusterScopedEventNamespace) {
var configurationService = mock(ConfigurationService.class);
when(configurationService.getLeaderElectionConfiguration())
diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorderconfigured/ConfiguredEventRecorderCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorderconfigured/ConfiguredEventRecorderCustomResource.java
new file mode 100644
index 0000000000..0e0e3cab5d
--- /dev/null
+++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorderconfigured/ConfiguredEventRecorderCustomResource.java
@@ -0,0 +1,30 @@
+/*
+ * 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.eventrecorderconfigured;
+
+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("ConfiguredEventRecorderCustomResource")
+@ShortNames("cerc")
+public class ConfiguredEventRecorderCustomResource extends CustomResource
+ implements Namespaced {}
diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorderconfigured/ConfiguredEventRecorderIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorderconfigured/ConfiguredEventRecorderIT.java
new file mode 100644
index 0000000000..537aa38f8d
--- /dev/null
+++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorderconfigured/ConfiguredEventRecorderIT.java
@@ -0,0 +1,168 @@
+/*
+ * 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.eventrecorderconfigured;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.junit.jupiter.api.AfterEach;
+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.HasMetadata;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+import io.fabric8.kubernetes.api.model.OwnerReference;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.KubernetesClientBuilder;
+import io.javaoperatorsdk.annotation.Sample;
+import io.javaoperatorsdk.operator.api.event.DefaultEventRecorder;
+import io.javaoperatorsdk.operator.api.event.DefaultEventSink;
+import io.javaoperatorsdk.operator.api.event.EventKeyStrategy;
+import io.javaoperatorsdk.operator.api.event.EventRecord;
+import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension;
+
+import static io.javaoperatorsdk.operator.baseapi.eventrecorderconfigured.ConfiguredEventRecorderReconciler.AGGREGATED_REASON;
+import static io.javaoperatorsdk.operator.baseapi.eventrecorderconfigured.ConfiguredEventRecorderReconciler.NAMED_REASON;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+@Sample(
+ tldr = "Configuring how the event recorder aggregates, names and owns events",
+ description =
+ """
+ Demonstrates configuring the event recorder with a default aggregation key strategy, a \
+ naming strategy and owner references. Aggregating by reason resolves repeated occurrences \
+ with changing messages to one event whose count grows and whose message is replaced with \
+ the latest one. A naming strategy gives events predictable names instead of the default \
+ identity hash. Owner references relate the events to the object they are about.
+ """)
+class ConfiguredEventRecorderIT {
+
+ private static final String TEST_RESOURCE_NAME = "test1";
+
+ private final KubernetesClient sinkClient = new KubernetesClientBuilder().build();
+
+ private final ConfiguredEventRecorderReconciler reconciler =
+ new ConfiguredEventRecorderReconciler();
+
+ @RegisterExtension
+ LocallyRunOperatorExtension extension =
+ LocallyRunOperatorExtension.builder()
+ .withReconciler(reconciler)
+ .withConfigurationService(o -> o.withEventRecorder(configuredEventRecorder()))
+ .build();
+
+ @AfterEach
+ void closeSinkClient() {
+ sinkClient.close();
+ }
+
+ @Test
+ void aggregatesOccurrencesWithChangingMessagesOntoOneEvent() {
+ var resource = extension.create(testResource());
+ await().untilAsserted(() -> assertThat(reconciler.getNumberOfExecutions()).isPositive());
+
+ // reconcile once more: aggregating by reason resolves the new messages to the same events
+ resource.getMetadata().setAnnotations(Map.of("reconcile", "again"));
+ extension.replace(resource);
+
+ await()
+ .untilAsserted(
+ () -> {
+ assertThat(reconciler.getNumberOfExecutions()).isGreaterThanOrEqualTo(2);
+
+ assertThat(eventsWithReason(AGGREGATED_REASON))
+ .singleElement()
+ .satisfies(
+ event -> {
+ assertThat(event.getCount()).isGreaterThanOrEqualTo(2);
+ assertThat(event.getMessage())
+ .isEqualTo("something changed in execution " + event.getCount());
+ });
+ });
+ }
+
+ @Test
+ void namesEventsThroughTheConfiguredNamingStrategy() {
+ extension.create(testResource());
+
+ await()
+ .untilAsserted(
+ () ->
+ assertThat(eventsWithReason(NAMED_REASON))
+ .singleElement()
+ .extracting(event -> event.getMetadata().getName())
+ .isEqualTo(TEST_RESOURCE_NAME + "-status-report"));
+ }
+
+ @Test
+ void setsOwnerReferencesOnTheEventsItRecords() {
+ var resource = extension.create(testResource());
+
+ await()
+ .untilAsserted(
+ () -> {
+ var events = eventsForTestResource();
+ assertThat(events).isNotEmpty();
+ assertThat(events)
+ .allSatisfy(
+ event ->
+ assertThat(event.getMetadata().getOwnerReferences())
+ .singleElement()
+ .returns(
+ "ConfiguredEventRecorderCustomResource", OwnerReference::getKind)
+ .returns(resource.getMetadata().getUid(), OwnerReference::getUid));
+ });
+ }
+
+ private List eventsWithReason(String reason) {
+ return eventsForTestResource().stream().filter(e -> reason.equals(e.getReason())).toList();
+ }
+
+ @SuppressWarnings("resource")
+ private List eventsForTestResource() {
+ return extension
+ .getKubernetesClient()
+ .v1()
+ .events()
+ .inNamespace(extension.getNamespace())
+ .withField("involvedObject.name", TEST_RESOURCE_NAME)
+ .list()
+ .getItems();
+ }
+
+ private ConfiguredEventRecorderCustomResource testResource() {
+ var resource = new ConfiguredEventRecorderCustomResource();
+ resource.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build());
+ return resource;
+ }
+
+ private DefaultEventRecorder configuredEventRecorder() {
+ return DefaultEventRecorder.builder(new DefaultEventSink(sinkClient))
+ .namingStrategy(ConfiguredEventRecorderIT::statusReportName)
+ .keyStrategy(EventKeyStrategy.byReason())
+ .ownerReference(true)
+ .build();
+ }
+
+ private static Optional statusReportName(HasMetadata regarding, EventRecord record) {
+ return NAMED_REASON.equals(record.reason())
+ ? Optional.of(regarding.getMetadata().getName() + "-status-report")
+ : Optional.empty();
+ }
+}
diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorderconfigured/ConfiguredEventRecorderReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorderconfigured/ConfiguredEventRecorderReconciler.java
new file mode 100644
index 0000000000..af4d136a4d
--- /dev/null
+++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorderconfigured/ConfiguredEventRecorderReconciler.java
@@ -0,0 +1,49 @@
+/*
+ * 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.eventrecorderconfigured;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration;
+import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
+import io.javaoperatorsdk.operator.api.reconciler.UpdateControl;
+
+@ControllerConfiguration(generationAwareEventProcessing = false)
+public class ConfiguredEventRecorderReconciler
+ implements Reconciler {
+
+ public static final String NAMED_REASON = "StatusReport";
+ public static final String AGGREGATED_REASON = "SomethingChanged";
+
+ private final AtomicInteger numberOfExecutions = new AtomicInteger();
+
+ @Override
+ public UpdateControl reconcile(
+ ConfiguredEventRecorderCustomResource resource,
+ Context context) {
+ var execution = numberOfExecutions.incrementAndGet();
+ context.eventRecorder().warn(NAMED_REASON, "status after execution " + execution);
+ context
+ .eventRecorder()
+ .normal(AGGREGATED_REASON, "something changed in execution " + execution);
+ return UpdateControl.noUpdate();
+ }
+
+ public int getNumberOfExecutions() {
+ return numberOfExecutions.get();
+ }
+}