Skip to content

Commit 29156fa

Browse files
afalhambra-hivemqcsviri
authored andcommitted
feat: support custom event naming on the event recorder
Signed-off-by: Antonio Fernandez Alhambra <antonio.alhambra@hivemq.com>
1 parent 398085b commit 29156fa

4 files changed

Lines changed: 280 additions & 11 deletions

File tree

‎operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/DefaultEventRecorder.java‎

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.time.temporal.ChronoUnit;
2525
import java.util.HexFormat;
2626
import java.util.Objects;
27+
import java.util.regex.Pattern;
2728

2829
import org.slf4j.Logger;
2930
import org.slf4j.LoggerFactory;
@@ -48,10 +49,11 @@
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
*/
5658
public 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(
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright Java Operator SDK Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.javaoperatorsdk.operator.api.event;
17+
18+
import java.util.Optional;
19+
20+
import io.fabric8.kubernetes.api.model.HasMetadata;
21+
import io.javaoperatorsdk.operator.api.reconciler.Experimental;
22+
23+
import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE;
24+
25+
/**
26+
* Names the event recorded about an object. The name is what the sink looks a recorded event up by,
27+
* so it is also the aggregation identity: two records resolving to the same name are counted as
28+
* occurrences of one event. A name must therefore be unique among the events it should not
29+
* aggregate with, and stable across operator restarts and replicas.
30+
*
31+
* <p>A name must be a valid RFC 1123 DNS subdomain: at most 253 lowercase alphanumeric characters,
32+
* {@code -} or {@code .}, starting and ending with an alphanumeric character. Names longer than the
33+
* limit are truncated. A name derived from the object and a fixed lowercase suffix (such as {@code
34+
* <object>-status-report}) satisfies all of this by construction.
35+
*
36+
* <p>An empty result or an invalid name falls back to the default {@code <object>.<identity hash>}
37+
* name, rather than the event being lost to the API server rejecting the name.
38+
*
39+
* <p>Implementations are called from concurrent reconciliations and must be thread safe.
40+
*/
41+
@Experimental(API_MIGHT_CHANGE)
42+
@FunctionalInterface
43+
public interface EventNamingStrategy {
44+
45+
Optional<String> nameFor(HasMetadata regarding, EventRecord record);
46+
47+
/** No custom naming: every event gets the default {@code <object>.<identity hash>} name. */
48+
static EventNamingStrategy none() {
49+
return (regarding, record) -> Optional.empty();
50+
}
51+
}

‎operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/EventRecord.java‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public final class EventRecord {
3232
private final EventType type;
3333
private final String reason;
3434
private final String message;
35+
private final String name;
3536
private final String key;
3637
private final Boolean ownedByRegarding;
3738
private final String action;
@@ -43,6 +44,7 @@ private EventRecord(Builder builder) {
4344
this.type = builder.type;
4445
this.reason = builder.reason;
4546
this.message = builder.message;
47+
this.name = builder.name;
4648
this.key = builder.key;
4749
this.action = builder.action;
4850
this.reportingComponent = builder.reportingComponent;
@@ -77,9 +79,21 @@ public String message() {
7779
return message;
7880
}
7981

82+
/**
83+
* The name of the recorded event, overriding the recorder's naming. The name is the aggregation
84+
* identity and must be a valid RFC 1123 DNS subdomain, see {@link EventNamingStrategy}; an
85+
* invalid name falls back to the default name. A blank name is treated as unset.
86+
*/
87+
public Optional<String> name() {
88+
return Optional.ofNullable(name);
89+
}
90+
8091
/**
8192
* Identifies this event among the events about the same object, so that repeated occurrences
8293
* resolve to the same event rather than to one event each.
94+
*
95+
* <p>The key is ignored when the record sets a {@link #name()} or the recorder's naming strategy
96+
* resolves one: the name is then the aggregation identity on its own.
8397
*/
8498
public Optional<String> key() {
8599
return Optional.ofNullable(key);
@@ -129,6 +143,7 @@ public static final class Builder {
129143
private EventType type = EventType.NORMAL;
130144
private String reason;
131145
private String message;
146+
private String name;
132147
private String key;
133148
private Boolean ownedByRegarding;
134149
private String action;
@@ -153,6 +168,12 @@ public Builder message(String message) {
153168
return this;
154169
}
155170

171+
/** Sets the name of the recorded event, see {@link EventRecord#name()}. */
172+
public Builder name(String name) {
173+
this.name = name;
174+
return this;
175+
}
176+
156177
/** Sets the key identifying this event, see {@link EventRecord#key()}. */
157178
public Builder key(String key) {
158179
this.key = key;

0 commit comments

Comments
 (0)