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/6] 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/6] 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/6] 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 69954688320e06a5537422f4e3afdb01f1949c5c 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/6] 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..a553350caa 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() From 6727ba0656aaf93db529c2611762ede34502fcd3 Mon Sep 17 00:00:00 2001 From: Qi Tan <16416018+TQJADE@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:59:40 -0700 Subject: [PATCH 5/6] Add Event Get Rbac --- .../docs/documentation/operations/configuration.md | 13 +++++++++++++ .../en/docs/documentation/operations/helm-chart.md | 6 ++++++ helm/generic-helm-chart/templates/clusterrole.yaml | 4 ++++ helm/generic-helm-chart/tests/clusterrole_test.yaml | 1 + 4 files changed, 24 insertions(+) diff --git a/docs/content/en/docs/documentation/operations/configuration.md b/docs/content/en/docs/documentation/operations/configuration.md index a8ab6b932f..d12af1aead 100644 --- a/docs/content/en/docs/documentation/operations/configuration.md +++ b/docs/content/en/docs/documentation/operations/configuration.md @@ -300,6 +300,19 @@ All operator-level keys are prefixed with `josdk.`. |---|---|---| | `josdk.events.cluster-scoped-namespace` | `String` | Namespace to record events about cluster scoped resources in (defaults to `default`) | +Recording events requires the operator's service account to be allowed `get` and `create` on +`events` in the core (`""`) API group. The lookup is what lets a repeated event resolve to the event +already recorded instead of creating a duplicate, so `get` is as necessary as `create`. The +[generic Helm chart](helm-chart.md) grants both. If you write your own RBAC and the permission is +missing, recording fails silently as far as reconciliation is concerned: the failure is swallowed so +that it cannot break a reconciliation, and only shows up as a warning in the operator log. + +Note that events live in a namespace of their own choosing, which is the namespace of the object +they are about, and for cluster scoped objects the namespace configured above. A namespaced `Role` +therefore has to grant the permission in every namespace events are recorded in — including +`default`, or whatever `josdk.events.cluster-scoped-namespace` is set to, if the operator reconciles +cluster scoped resources. A `ClusterRole` covers all of them at once. + #### Leader Election Leader election is activated when at least one `josdk.leader-election.*` key is present. diff --git a/docs/content/en/docs/documentation/operations/helm-chart.md b/docs/content/en/docs/documentation/operations/helm-chart.md index a0901f31f7..1a46d6f9f6 100644 --- a/docs/content/en/docs/documentation/operations/helm-chart.md +++ b/docs/content/en/docs/documentation/operations/helm-chart.md @@ -49,6 +49,12 @@ secondaryResources: Primary resources get read/watch/patch permissions and status sub-resource access. Secondary resources get full CRUD permissions. Default verbs can be overridden per resource entry. +The ClusterRole always grants `get`, `create` and `patch` on `events` in the core API group, so +recording events works without any extra configuration. `get` is needed because an event is looked +up by name before it is created, so that recording the same event again resolves to the event +already recorded rather than to a duplicate. See +[Events](configuration.md#events) for what this means if you write your own RBAC. + ### Operator Environment The chart injects `OPERATOR_NAMESPACE` automatically. You can optionally set `WATCH_NAMESPACE` to diff --git a/helm/generic-helm-chart/templates/clusterrole.yaml b/helm/generic-helm-chart/templates/clusterrole.yaml index 04070da489..92be933e0b 100644 --- a/helm/generic-helm-chart/templates/clusterrole.yaml +++ b/helm/generic-helm-chart/templates/clusterrole.yaml @@ -71,11 +71,15 @@ rules: {{- end }} {{- end }} # Event permissions - for recording events +# "get" is required because the event sink looks an event up by name before creating it, so that a +# repeat of an event resolves to the one already recorded instead of a duplicate. Without it, +# recording fails and is only visible as a warning in the operator log. - apiGroups: - "" resources: - events verbs: + - get - create - patch {{- /* Leader election - Lease permissions */}} diff --git a/helm/generic-helm-chart/tests/clusterrole_test.yaml b/helm/generic-helm-chart/tests/clusterrole_test.yaml index 7cfdf4dc99..86e0aa2589 100644 --- a/helm/generic-helm-chart/tests/clusterrole_test.yaml +++ b/helm/generic-helm-chart/tests/clusterrole_test.yaml @@ -44,6 +44,7 @@ tests: resources: - events verbs: + - get - create - patch From 4d409371e6dcded74b2a221acb9698c579c97c79 Mon Sep 17 00:00:00 2001 From: Qi Tan <16416018+TQJADE@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:42:52 -0700 Subject: [PATCH 6/6] Change events to event and add patch --- .../documentation/operations/configuration.md | 9 +-- .../documentation/operations/helm-chart.md | 6 +- .../templates/clusterrole.yaml | 6 +- .../operator/RegisteredController.java | 2 +- .../api/config/ConfigurationService.java | 2 +- .../DefaultEventRecorder.java | 5 +- .../operator/api/event/DefaultEventSink.java | 66 +++++++++++++++++++ .../api/{events => event}/EventRecord.java | 2 +- .../api/{events => event}/EventRecorder.java | 2 +- .../api/{events => event}/EventSink.java | 2 +- .../api/{events => event}/EventType.java | 2 +- .../ResourceEventRecorder.java | 2 +- .../operator/api/events/DefaultEventSink.java | 46 ------------- .../operator/api/reconciler/Context.java | 2 +- .../api/reconciler/DefaultContext.java | 2 +- .../operator/processing/Controller.java | 6 +- .../DefaultEventRecorderTest.java | 2 +- .../eventrecorder/EventRecorderIT.java | 36 +++++++++- .../EventRecorderReconciler.java | 11 ++++ 19 files changed, 138 insertions(+), 73 deletions(-) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/{events => event}/DefaultEventRecorder.java (98%) create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/DefaultEventSink.java rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/{events => event}/EventRecord.java (99%) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/{events => event}/EventRecorder.java (97%) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/{events => event}/EventSink.java (96%) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/{events => event}/EventType.java (96%) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/{events => event}/ResourceEventRecorder.java (96%) delete mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/DefaultEventSink.java rename operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/{events => event}/DefaultEventRecorderTest.java (99%) diff --git a/docs/content/en/docs/documentation/operations/configuration.md b/docs/content/en/docs/documentation/operations/configuration.md index d12af1aead..1d74cf3f0f 100644 --- a/docs/content/en/docs/documentation/operations/configuration.md +++ b/docs/content/en/docs/documentation/operations/configuration.md @@ -300,10 +300,11 @@ All operator-level keys are prefixed with `josdk.`. |---|---|---| | `josdk.events.cluster-scoped-namespace` | `String` | Namespace to record events about cluster scoped resources in (defaults to `default`) | -Recording events requires the operator's service account to be allowed `get` and `create` on -`events` in the core (`""`) API group. The lookup is what lets a repeated event resolve to the event -already recorded instead of creating a duplicate, so `get` is as necessary as `create`. The -[generic Helm chart](helm-chart.md) grants both. If you write your own RBAC and the permission is +Recording events requires the operator's service account to be allowed `get`, `create` and `patch` on +`events` in the core (`""`) API group. All three are used: an event that does not exist yet is +created, and an event that does is a repeat of one already recorded, which is patched to count the +new occurrence instead of being recorded a second time. The +[generic Helm chart](helm-chart.md) grants all three. If you write your own RBAC and a permission is missing, recording fails silently as far as reconciliation is concerned: the failure is swallowed so that it cannot break a reconciliation, and only shows up as a warning in the operator log. diff --git a/docs/content/en/docs/documentation/operations/helm-chart.md b/docs/content/en/docs/documentation/operations/helm-chart.md index 1a46d6f9f6..92e11dfee6 100644 --- a/docs/content/en/docs/documentation/operations/helm-chart.md +++ b/docs/content/en/docs/documentation/operations/helm-chart.md @@ -50,9 +50,9 @@ Primary resources get read/watch/patch permissions and status sub-resource acces Secondary resources get full CRUD permissions. Default verbs can be overridden per resource entry. The ClusterRole always grants `get`, `create` and `patch` on `events` in the core API group, so -recording events works without any extra configuration. `get` is needed because an event is looked -up by name before it is created, so that recording the same event again resolves to the event -already recorded rather than to a duplicate. See +recording events works without any extra configuration. All three are needed: an event is looked up +by name before it is created, so that recording the same event again resolves to the event already +recorded, and that event is then patched to count the new occurrence rather than recorded again. See [Events](configuration.md#events) for what this means if you write your own RBAC. ### Operator Environment diff --git a/helm/generic-helm-chart/templates/clusterrole.yaml b/helm/generic-helm-chart/templates/clusterrole.yaml index 92be933e0b..f189e93115 100644 --- a/helm/generic-helm-chart/templates/clusterrole.yaml +++ b/helm/generic-helm-chart/templates/clusterrole.yaml @@ -71,9 +71,9 @@ rules: {{- end }} {{- end }} # Event permissions - for recording events -# "get" is required because the event sink looks an event up by name before creating it, so that a -# repeat of an event resolves to the one already recorded instead of a duplicate. Without it, -# recording fails and is only visible as a warning in the operator log. +# All three verbs are used: an event is looked up by name ("get") so that a repeat of it resolves to +# the event already recorded, which is then "patch"ed to count the new occurrence instead of being +# "create"d a second time. Without them recording fails, visible only as a warning in the log. - apiGroups: - "" resources: 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 b8ffb92ac1..e6aa6cbce6 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RegisteredController.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RegisteredController.java @@ -18,7 +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.api.event.EventRecorder; import io.javaoperatorsdk.operator.health.ControllerHealthInfo; public interface RegisteredController

extends NamespaceChangeable { 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 dc0ebf2c90..c3d1636983 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java @@ -34,7 +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.event.DefaultEventRecorder; import io.javaoperatorsdk.operator.api.monitoring.Metrics; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; 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/event/DefaultEventRecorder.java similarity index 98% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/DefaultEventRecorder.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/DefaultEventRecorder.java index a553350caa..abf4ab025f 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/event/DefaultEventRecorder.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.events; +package io.javaoperatorsdk.operator.api.event; import java.net.InetAddress; import java.net.UnknownHostException; @@ -48,7 +48,8 @@ * *

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}. + * recorded for it. Repeat occurrences are then counted on that event rather than recorded as copies + * of it, see {@link DefaultEventSink}. */ public class DefaultEventRecorder implements EventRecorder { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/DefaultEventSink.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/DefaultEventSink.java new file mode 100644 index 0000000000..b51fe2fbbf --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/DefaultEventSink.java @@ -0,0 +1,66 @@ +/* + * 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 io.fabric8.kubernetes.api.model.Event; +import io.fabric8.kubernetes.api.model.EventBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; + +import static java.util.Objects.requireNonNullElse; + +/** + * 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 that does not exist yet is created. An event that does is a repeat of one already + * recorded, since the name of an event is derived from everything that identifies it (see {@link + * DefaultEventRecorder}), and is aggregated onto the recorded one by patching its {@code count} and + * {@code lastTimestamp}. That is what makes {@code kubectl describe} report a repeating event once, + * as {@code (x12 over 3m)}, rather than filling the event list with copies of it. + * + *

Recording therefore needs {@code get}, {@code create} and {@code patch} on {@code events} in + * the core API group. The patch is made on the resource version the event was read at, so two + * writers counting the same occurrence at the same time conflict, and the one that loses leaves the + * occurrence uncounted and the conflict logged by {@link DefaultEventRecorder}, recording being + * best effort. + */ +public class DefaultEventSink implements EventSink { + + private final KubernetesClient client; + + public DefaultEventSink(KubernetesClient client) { + this.client = client; + } + + @Override + public void emit(Event event) { + var events = client.v1().events().inNamespace(event.getMetadata().getNamespace()); + var name = event.getMetadata().getName(); + var existing = events.withName(name).get(); + if (existing == null) { + events.resource(event).create(); + } else { + var aggregated = + new EventBuilder(existing) + .withCount(requireNonNullElse(existing.getCount(), 1) + 1) + .withLastTimestamp(event.getLastTimestamp()) + .withMessage(event.getMessage()) + .build(); + events.withName(name).patch(aggregated); + } + } +} 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/event/EventRecord.java similarity index 99% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventRecord.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/EventRecord.java index 0c0cf7bcd8..e7b736abc0 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/event/EventRecord.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.events; +package io.javaoperatorsdk.operator.api.event; import java.util.HashMap; import java.util.Map; 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/event/EventRecorder.java similarity index 97% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventRecorder.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/EventRecorder.java index be6ba6c357..a7d66d8e54 100644 --- 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/event/EventRecorder.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.events; +package io.javaoperatorsdk.operator.api.event; import io.fabric8.kubernetes.api.model.HasMetadata; 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/event/EventSink.java similarity index 96% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventSink.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/EventSink.java index 051eb56b36..54763902a8 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/event/EventSink.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.events; +package io.javaoperatorsdk.operator.api.event; import io.fabric8.kubernetes.api.model.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/event/EventType.java similarity index 96% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventType.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/EventType.java index bd82b09e8e..5cddc206d6 100644 --- 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/event/EventType.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.events; +package io.javaoperatorsdk.operator.api.event; /** * Type of a recorded Kubernetes event. 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/event/ResourceEventRecorder.java similarity index 96% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/ResourceEventRecorder.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/ResourceEventRecorder.java index 8cb887c977..a1fcd6d272 100644 --- 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/event/ResourceEventRecorder.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.events; +package io.javaoperatorsdk.operator.api.event; /** * An {@link EventRecorder} bound to a single object, typically the primary resource of the current 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 deleted file mode 100644 index 6b57052a8b..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/DefaultEventSink.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * 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. - * - *

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 { - - private final KubernetesClient client; - - public DefaultEventSink(KubernetesClient client) { - this.client = client; - } - - @Override - public void emit(Event event) { - 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/reconciler/Context.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java index 9642f2b4ea..df9c19b263 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,7 +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.event.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; 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 3ee5ae99c1..1c90c7535f 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,7 +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.event.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; 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 8ace94e9a5..285eb3988c 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 @@ -40,9 +40,9 @@ 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.event.DefaultEventRecorder; +import io.javaoperatorsdk.operator.api.event.DefaultEventSink; +import io.javaoperatorsdk.operator.api.event.EventRecorder; import io.javaoperatorsdk.operator.api.monitoring.Metrics; import io.javaoperatorsdk.operator.api.monitoring.Metrics.ControllerExecution; import io.javaoperatorsdk.operator.api.reconciler.Cleaner; 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/event/DefaultEventRecorderTest.java similarity index 99% rename from operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/events/DefaultEventRecorderTest.java rename to operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/event/DefaultEventRecorderTest.java index c1571636ba..77ad3408d7 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/event/DefaultEventRecorderTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.events; +package io.javaoperatorsdk.operator.api.event; import java.util.ArrayList; import java.util.List; 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 index e23468812e..5c1319d884 100644 --- 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 @@ -16,6 +16,7 @@ package io.javaoperatorsdk.operator.baseapi.eventrecorder; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -35,15 +36,18 @@ 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. + attributed to the reporting controller, and that recording the same event again counts the \ + occurrence on the event already recorded instead of recording it a second time. """) class EventRecorderIT { public static final String TEST_RESOURCE_NAME = "test1"; + private final EventRecorderReconciler reconciler = new EventRecorderReconciler(); + @RegisterExtension LocallyRunOperatorExtension extension = - LocallyRunOperatorExtension.builder().withReconciler(new EventRecorderReconciler()).build(); + LocallyRunOperatorExtension.builder().withReconciler(reconciler).build(); @Test void recordsEventsAboutThePrimaryResource() { @@ -74,6 +78,34 @@ void recordsEventsAboutThePrimaryResource() { }); } + @Test + void countsRepeatedOccurrencesOnTheEventAlreadyRecorded() { + var resource = extension.create(testResource()); + await().untilAsserted(() -> assertThat(reconciler.getNumberOfExecutions()).isPositive()); + + // have the resource reconciled once more. The reconciliation records the same event as the one + // before it did, which is not recorded a second time: the occurrence is counted on the event + // recorded for the earlier reconciliation, which is what makes the event say it happened twice. + resource.getMetadata().setAnnotations(Map.of("reconcile", "again")); + extension.replace(resource); + + await() + .untilAsserted( + () -> { + assertThat(reconciler.getNumberOfExecutions()).isGreaterThanOrEqualTo(2); + + var normal = + eventsForTestResource().stream() + .filter(e -> EventRecorderReconciler.NORMAL_REASON.equals(e.getReason())) + .toList(); + + assertThat(normal).hasSize(1); + assertThat(normal.get(0).getCount()).isGreaterThanOrEqualTo(2); + assertThat(normal.get(0).getFirstTimestamp()).isNotNull(); + assertThat(normal.get(0).getLastTimestamp()).isNotNull(); + }); + } + private List eventsForTestResource() { return extension .getKubernetesClient() 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 index 8e07719768..8108eb4031 100644 --- 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 @@ -15,20 +15,31 @@ */ package io.javaoperatorsdk.operator.baseapi.eventrecorder; +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 EventRecorderReconciler implements Reconciler { public static final String NORMAL_REASON = "Reconciled"; public static final String WARNING_REASON = "SomethingIsOff"; + private final AtomicInteger numberOfExecutions = new AtomicInteger(); + @Override public UpdateControl reconcile( EventRecorderCustomResource resource, Context context) { + numberOfExecutions.incrementAndGet(); context.eventRecorder().normal(NORMAL_REASON, "resource reconciled"); context.eventRecorder().warn(WARNING_REASON, "this is a warning about the resource"); return UpdateControl.noUpdate(); } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } }