From ef224bb1c935db3843773c2c2f041c6e2d5011f7 Mon Sep 17 00:00:00 2001 From: Qi Tan <16416018+TQJADE@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:25:30 -0700 Subject: [PATCH 1/4] Add Event Recorder --- .../operator/RegisteredController.java | 14 ++ .../api/config/ConfigurationService.java | 18 ++ .../config/ConfigurationServiceOverrider.java | 21 ++ .../api/events/DefaultEventRecorder.java | 195 +++++++++++++++++ .../operator/api/events/DefaultEventSink.java | 38 ++++ .../operator/api/events/EventRecord.java | 171 +++++++++++++++ .../operator/api/events/EventRecorder.java | 52 +++++ .../operator/api/events/EventSink.java | 35 +++ .../operator/api/events/EventType.java | 42 ++++ .../api/events/ResourceEventRecorder.java | 35 +++ .../operator/api/reconciler/Context.java | 10 + .../api/reconciler/DefaultContext.java | 6 + .../operator/processing/Controller.java | 20 ++ .../api/events/DefaultEventRecorderTest.java | 207 ++++++++++++++++++ .../EventRecorderCustomResource.java | 29 +++ .../eventrecorder/EventRecorderIT.java | 93 ++++++++ .../EventRecorderReconciler.java | 34 +++ 17 files changed, 1020 insertions(+) create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/DefaultEventRecorder.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/DefaultEventSink.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventRecord.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventRecorder.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventSink.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventType.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/ResourceEventRecorder.java create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/events/DefaultEventRecorderTest.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorder/EventRecorderCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorder/EventRecorderIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/eventrecorder/EventRecorderReconciler.java 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..576f66e02a --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/DefaultEventRecorder.java @@ -0,0 +1,195 @@ +/* + * 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.time.Instant; +import java.time.temporal.ChronoUnit; +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; + +/** + * 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()}. + */ +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; + + 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)) + .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 after the object they are about plus a unique suffix, the same convention the Go + * client uses. Note that once aggregation is supported the name has to be derived from the + * deduplication key instead, so that an existing event can be found and its count increased. + */ + private String eventName(HasMetadata regarding) { + var suffix = "." + Long.toHexString(System.nanoTime()); + var prefix = regarding.getMetadata().getName(); + var maxPrefixLength = MAX_NAME_LENGTH - suffix.length(); + if (prefix.length() > maxPrefixLength) { + prefix = prefix.substring(0, maxPrefixLength); + } + return prefix + suffix; + } + + 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..938b4ea2ae --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/DefaultEventSink.java @@ -0,0 +1,38 @@ +/* + * 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. + */ +public class DefaultEventSink implements EventSink { + + private final KubernetesClient client; + + public DefaultEventSink(KubernetesClient client) { + this.client = client; + } + + @Override + public void emit(Event event) { + client.v1().events().inNamespace(event.getMetadata().getNamespace()).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..43075325fd --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventRecord.java @@ -0,0 +1,171 @@ +/* + * 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 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.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; + } + + /** + * 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 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; + } + + 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..69d1379b92 --- /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. Implementations may throw, callers are expected to isolate failures. + * + * @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..50400cfb0b 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,15 @@ 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 + */ + ResourceEventRecorder eventRecorder(); + /** 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..b7c683ac13 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/events/DefaultEventRecorderTest.java @@ -0,0 +1,207 @@ +/* + * 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()); + } + + 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/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(); + } +} From 5679da2ce7fa930e495c8f83ed27e8ae586312ec Mon Sep 17 00:00:00 2001 From: Qi Tan <16416018+TQJADE@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:40:19 -0700 Subject: [PATCH 2/4] Fix unit test failure --- .../en/docs/documentation/operations/configuration.md | 6 ++++++ .../operator/config/loader/ConfigLoader.java | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) 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/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 From 4720aa136a6ea13b626c3119ee52047c28309c26 Mon Sep 17 00:00:00 2001 From: Qi Tan <16416018+TQJADE@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:19:59 -0700 Subject: [PATCH 3/4] Address comments and drop duplicate event --- .../api/events/DefaultEventRecorder.java | 39 +++++++++++++++---- .../operator/api/events/DefaultEventSink.java | 10 ++++- .../operator/api/events/EventRecord.java | 17 ++++++++ .../operator/api/events/EventSink.java | 2 +- .../operator/api/reconciler/Context.java | 6 ++- 5 files changed, 64 insertions(+), 10 deletions(-) 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 index 576f66e02a..114383ee76 100644 --- 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 @@ -30,6 +30,8 @@ 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 @@ -39,6 +41,10 @@ * #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 { @@ -52,6 +58,9 @@ public class DefaultEventRecorder implements EventRecorder { */ 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'; + private final String reportingController; private final String reportingInstance; private final String clusterScopedEventNamespace; @@ -121,7 +130,7 @@ protected Event toEvent(HasMetadata regarding, EventRecord record) { var builder = new EventBuilder() .withNewMetadata() - .withName(eventName(regarding)) + .withName(eventName(regarding, record)) .withNamespace(eventNamespace(regarding)) .withLabels(record.labels()) .withAnnotations(record.annotations()) @@ -149,13 +158,29 @@ private String eventNamespace(HasMetadata regarding) { } /** - * Names events after the object they are about plus a unique suffix, the same convention the Go - * client uses. Note that once aggregation is supported the name has to be derived from the - * deduplication key instead, so that an existing event can be found and its count increased. + * 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) { - var suffix = "." + Long.toHexString(System.nanoTime()); - var prefix = regarding.getMetadata().getName(); + 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 = "." + Integer.toHexString(identity.hashCode() & 0x7FFFFFFF); + var prefix = metadata.getName(); var maxPrefixLength = MAX_NAME_LENGTH - suffix.length(); if (prefix.length() > maxPrefixLength) { prefix = prefix.substring(0, maxPrefixLength); 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 index 938b4ea2ae..6b57052a8b 100644 --- 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 @@ -22,6 +22,10 @@ * 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 { @@ -33,6 +37,10 @@ public DefaultEventSink(KubernetesClient client) { @Override public void emit(Event event) { - client.v1().events().inNamespace(event.getMetadata().getNamespace()).resource(event).create(); + 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 index 43075325fd..0c0cf7bcd8 100644 --- 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 @@ -32,6 +32,7 @@ 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; @@ -41,6 +42,7 @@ 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); @@ -73,6 +75,14 @@ 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. @@ -109,6 +119,7 @@ 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<>(); @@ -131,6 +142,12 @@ public Builder message(String 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; 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 index 69d1379b92..051eb56b36 100644 --- 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 @@ -27,7 +27,7 @@ public interface EventSink { /** - * Delivers the event. Implementations may throw, callers are expected to isolate failures. + * Delivers the event. * * @param event the event to deliver */ 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 50400cfb0b..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 @@ -220,8 +220,12 @@ default Optional getSecondaryResource( * {@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 */ - ResourceEventRecorder eventRecorder(); + 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(); From ccfaeb1ab7fa574bed735d4b3ffc8dc704c8d5e9 Mon Sep 17 00:00:00 2001 From: Qi Tan <16416018+TQJADE@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:50:31 -0700 Subject: [PATCH 4/4] Use Sha256 --- .../api/events/DefaultEventRecorder.java | 36 ++++++++++++++++++- .../api/events/DefaultEventRecorderTest.java | 23 ++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) 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 index 114383ee76..33bf4e7c49 100644 --- 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 @@ -17,8 +17,12 @@ 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; @@ -61,6 +65,11 @@ public class DefaultEventRecorder implements EventRecorder { /** 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; @@ -179,7 +188,7 @@ private String eventName(HasMetadata regarding, EventRecord record) { record.reportingComponent().orElse(reportingController), record.key().orElseGet(() -> requireNonNullElse(record.message(), ""))); - var suffix = "." + Integer.toHexString(identity.hashCode() & 0x7FFFFFFF); + var suffix = "." + identityDigest(identity); var prefix = metadata.getName(); var maxPrefixLength = MAX_NAME_LENGTH - suffix.length(); if (prefix.length() > maxPrefixLength) { @@ -188,6 +197,31 @@ private String eventName(HasMetadata regarding, EventRecord record) { 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()) 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 index b7c683ac13..c1571636ba 100644 --- 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 @@ -185,6 +185,29 @@ void reasonIsRequired() { .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()