2424import java .time .temporal .ChronoUnit ;
2525import java .util .HexFormat ;
2626import java .util .Objects ;
27+ import java .util .regex .Pattern ;
2728
2829import org .slf4j .Logger ;
2930import org .slf4j .LoggerFactory ;
4849 * can be overridden, see {@link
4950 * io.javaoperatorsdk.operator.api.config.ConfigurationService#clusterScopedEventNamespace()}.
5051 *
51- * <p>Events are named deterministically, after the object they are about plus a hash of everything
52- * that identifies the event, so that recording the same event again resolves to the event already
53- * recorded for it. Repeat occurrences are then counted on that event rather than recorded as copies
54- * of it, see {@link DefaultEventSink}.
52+ * <p>By default, events are named deterministically, after the object they are about plus a hash of
53+ * everything that identifies the event, so that recording the same event again resolves to the
54+ * event already recorded for it. Repeat occurrences are then counted on that event rather than
55+ * recorded as copies of it, see {@link DefaultEventSink}. How events aggregate, how they are named
56+ * and whether they carry an owner reference can be configured, see {@link #builder(EventSink)}.
5557 */
5658public class DefaultEventRecorder implements EventRecorder {
5759
@@ -73,17 +75,26 @@ public class DefaultEventRecorder implements EventRecorder {
7375
7476 private static final int IDENTITY_HASH_LENGTH = 32 ;
7577
78+ /** What the API server accepts as an object name, see RFC 1123 on DNS subdomains. */
79+ private static final Pattern RFC_1123_SUBDOMAIN =
80+ Pattern .compile ("[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\ .[a-z0-9]([-a-z0-9]*[a-z0-9])?)*" );
81+
7682 private final EventSink sink ;
83+ private final EventNamingStrategy namingStrategy ;
7784 private final EventKeyStrategy keyStrategy ;
7885 private final boolean ownerReference ;
7986
8087 public DefaultEventRecorder (EventSink sink ) {
81- this (sink , EventKeyStrategy .none (), false );
88+ this (sink , EventNamingStrategy . none (), EventKeyStrategy .none (), false );
8289 }
8390
8491 private DefaultEventRecorder (
85- EventSink sink , EventKeyStrategy keyStrategy , boolean ownerReference ) {
92+ EventSink sink ,
93+ EventNamingStrategy namingStrategy ,
94+ EventKeyStrategy keyStrategy ,
95+ boolean ownerReference ) {
8696 this .sink = sink ;
97+ this .namingStrategy = namingStrategy ;
8798 this .keyStrategy = keyStrategy ;
8899 this .ownerReference = ownerReference ;
89100 }
@@ -96,16 +107,25 @@ public static Builder builder(EventSink sink) {
96107 public static final class Builder {
97108
98109 private final EventSink sink ;
110+ private EventNamingStrategy namingStrategy = EventNamingStrategy .none ();
99111 private EventKeyStrategy keyStrategy = EventKeyStrategy .none ();
100112 private boolean ownerReference = false ;
101113
102114 private Builder (EventSink sink ) {
103115 this .sink = Objects .requireNonNull (sink , "sink must not be null" );
104116 }
105117
118+ /** The strategy naming recorded events, see {@link EventNamingStrategy}. */
119+ public Builder namingStrategy (EventNamingStrategy namingStrategy ) {
120+ this .namingStrategy =
121+ Objects .requireNonNull (namingStrategy , "namingStrategy must not be null" );
122+ return this ;
123+ }
124+
106125 /**
107126 * The strategy deriving the default aggregation key of records that do not set one, see {@link
108- * EventKeyStrategy}.
127+ * EventKeyStrategy}. The key is ignored for events whose name is set by the record or resolved
128+ * by the naming strategy, see {@link EventNamingStrategy}.
109129 */
110130 public Builder keyStrategy (EventKeyStrategy keyStrategy ) {
111131 this .keyStrategy = Objects .requireNonNull (keyStrategy , "keyStrategy must not be null" );
@@ -126,7 +146,7 @@ public Builder ownerReference(boolean ownerReference) {
126146 }
127147
128148 public DefaultEventRecorder build () {
129- return new DefaultEventRecorder (sink , keyStrategy , ownerReference );
149+ return new DefaultEventRecorder (sink , namingStrategy , keyStrategy , ownerReference );
130150 }
131151 }
132152
@@ -162,14 +182,17 @@ private static String resolve() {
162182 public void record (EventRecord event , Context <?> context ) {
163183 Objects .requireNonNull (context , "the context of the reconciliation must not be null" );
164184 Objects .requireNonNull (event , "event must not be null" );
185+ Event assembled = null ;
165186 try {
166- sink .emit (toEvent (context , event ), context );
187+ assembled = toEvent (context , event );
188+ sink .emit (assembled , context );
167189 } catch (Exception e ) {
168190 // recording an event must never break the caller: a controller that fails to reconcile
169191 // because it could not write an event is strictly worse than one that records nothing
170192 log .warn (
171- "Could not record {} event with reason {} for resource {} in namespace {}" ,
193+ "Could not record {} event named {} with reason {} for resource {} in namespace {}" ,
172194 event .type (),
195+ assembled != null ? assembled .getMetadata ().getName () : "unknown" ,
173196 event .reason (),
174197 context .getPrimaryResource ().getMetadata ().getName (),
175198 context .getPrimaryResource ().getMetadata ().getNamespace (),
@@ -249,6 +272,40 @@ private String eventNamespace(HasMetadata regarding, Context<?> context) {
249272 CLUSTER_SCOPED_EVENT_NAMESPACE );
250273 }
251274
275+ private String eventName (HasMetadata regarding , EventRecord record , String reportingController ) {
276+ return record
277+ .name ()
278+ .filter (name -> !name .isBlank ())
279+ .or (() -> namingStrategy .nameFor (regarding , record ).filter (name -> !name .isBlank ()))
280+ .map (DefaultEventRecorder ::truncateToMaxNameLength )
281+ .filter (DefaultEventRecorder ::isValidEventName )
282+ .orElseGet (() -> identityHashName (regarding , record , reportingController ));
283+ }
284+
285+ private static boolean isValidEventName (String name ) {
286+ if (RFC_1123_SUBDOMAIN .matcher (name ).matches ()) {
287+ return true ;
288+ }
289+ log .warn (
290+ "Falling back to the default event name: {} is not a valid RFC 1123 DNS subdomain" , name );
291+ return false ;
292+ }
293+
294+ private static String truncateToMaxNameLength (String name ) {
295+ if (name .length () <= MAX_NAME_LENGTH ) {
296+ return name ;
297+ }
298+ var truncated = name .substring (0 , MAX_NAME_LENGTH );
299+ while (truncated .endsWith ("-" ) || truncated .endsWith ("." )) {
300+ truncated = truncated .substring (0 , truncated .length () - 1 );
301+ }
302+ log .warn (
303+ "Truncated the name of event {} to {} to stay within the Kubernetes name limit" ,
304+ name ,
305+ truncated );
306+ return truncated ;
307+ }
308+
252309 /**
253310 * Names events {@code <object name>.<hash>}, following the convention of the Go client, hashing
254311 * everything that makes two events the same event: the object, the type, the reason, the
@@ -259,8 +316,12 @@ private String eventNamespace(HasMetadata regarding, Context<?> context) {
259316 *
260317 * <p>The object is identified by its uid, with the kind as a fallback for objects that do not
261318 * have one yet, such as a dependent resource that has only been built so far.
319+ *
320+ * <p>This is the fallback when the record does not set a name and the naming strategy resolves to
321+ * nothing, see {@link EventNamingStrategy}.
262322 */
263- private String eventName (HasMetadata regarding , EventRecord record , String reportingController ) {
323+ private String identityHashName (
324+ HasMetadata regarding , EventRecord record , String reportingController ) {
264325 var metadata = regarding .getMetadata ();
265326 var identity =
266327 String .join (
0 commit comments