Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,11 @@
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.event.EventRecorder;
import io.javaoperatorsdk.operator.health.ControllerHealthInfo;

public interface RegisteredController<P extends HasMetadata> extends NamespaceChangeable {

ControllerConfiguration<P> 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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import io.fabric8.kubernetes.client.KubernetesClientBuilder;
import io.fabric8.kubernetes.client.utils.KubernetesSerialization;
import io.javaoperatorsdk.operator.api.event.DefaultEventRecorder;
import io.javaoperatorsdk.operator.api.event.EventRecorder;
import io.javaoperatorsdk.operator.api.monitoring.Metrics;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.api.reconciler.Experimental;
Expand Down Expand Up @@ -295,6 +296,24 @@ default String clusterScopedEventNamespace() {
return DefaultEventRecorder.CLUSTER_SCOPED_EVENT_NAMESPACE;
}

/**
* The {@link EventRecorder} the controllers of the operator record their Kubernetes events
* through, to plug in a custom implementation, for example one that assembles events differently
* by extending {@link DefaultEventRecorder}, or one that records them somewhere else entirely.
*
* <p>When empty, which is the default, every controller gets a {@link DefaultEventRecorder} of
* its own. A recorder configured here is shared by all controllers of the operator instead, which
* is why the reconciliation an event is recorded from is passed to it per call rather than
* configured on it: implementations have to be stateless and thread safe.
*
* @return the event recorder to use for the whole operator, or an empty optional to let each
* controller use its own default one
*/
@Experimental(Experimental.API_MIGHT_CHANGE)
default Optional<EventRecorder> eventRecorder() {
return Optional.empty();
}

/**
* if true, operator stops if there are some issues with informers {@link
* io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource} or {@link
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.javaoperatorsdk.operator.Operator;
import io.javaoperatorsdk.operator.api.event.EventRecorder;
import io.javaoperatorsdk.operator.api.monitoring.Metrics;
import io.javaoperatorsdk.operator.api.reconciler.Experimental;
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory;
Expand All @@ -48,6 +49,7 @@ public class ConfigurationServiceOverrider {
private ExecutorService workflowExecutorService;
private LeaderElectionConfiguration leaderElectionConfiguration;
private String clusterScopedEventNamespace;
private EventRecorder eventRecorder;
private InformerStoppedHandler informerStoppedHandler;
private Boolean stopOnInformerErrorDuringStartup;
private Duration cacheSyncTimeout;
Expand Down Expand Up @@ -148,6 +150,25 @@ public ConfigurationServiceOverrider withClusterScopedEventNamespace(String name
return this;
}

/**
* Replaces the {@link EventRecorder} the controllers of the operator record their Kubernetes
* events through by the specified one, which is then shared by all of them. Use this to record
* events differently, for example through a subclass of {@link
* io.javaoperatorsdk.operator.api.event.DefaultEventRecorder} that assembles them another way, or
* by delegating to another system (e.g. emitting events to an external store).
*
* <p>When not set, every controller records its events through a recorder of its own, which
* attributes them to that controller.
*
* @param eventRecorder the event recorder to use for the whole operator
* @return this {@link ConfigurationServiceOverrider} for chained customization
*/
@Experimental(Experimental.API_MIGHT_CHANGE)
public ConfigurationServiceOverrider withEventRecorder(EventRecorder eventRecorder) {
this.eventRecorder = eventRecorder;
return this;
}

public ConfigurationServiceOverrider withInformerStoppedHandler(InformerStoppedHandler handler) {
this.informerStoppedHandler = handler;
return this;
Expand Down Expand Up @@ -297,6 +318,11 @@ public String clusterScopedEventNamespace() {
: original.clusterScopedEventNamespace();
}

@Override
public Optional<EventRecorder> eventRecorder() {
return eventRecorder != null ? Optional.of(eventRecorder) : original.eventRecorder();
}

@Override
public Optional<InformerStoppedHandler> getInformerStoppedHandler() {
return informerStoppedHandler != null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
import io.fabric8.kubernetes.api.model.HasMetadata;
import io.fabric8.kubernetes.api.model.ObjectReference;
import io.fabric8.kubernetes.api.model.ObjectReferenceBuilder;
import io.javaoperatorsdk.operator.api.config.LeaderElectionConfiguration;
import io.javaoperatorsdk.operator.api.reconciler.Context;

import static java.util.Objects.requireNonNullElse;

Expand Down Expand Up @@ -71,77 +73,75 @@ public class DefaultEventRecorder implements EventRecorder {

private static final int IDENTITY_HASH_LENGTH = 32;

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;
public DefaultEventRecorder(EventSink sink) {
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.
*
* <p>Resolved once and cached: it cannot change over the life of the process, and looking the
* host name up can hit the name service, which is not something to do on every recorded event.
*/
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";
return DefaultReportingInstance.VALUE;
}

private static final class DefaultReportingInstance {
private static final String VALUE = resolve();

private static String resolve() {
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");
public void record(EventRecord event, Context<?> context) {
Objects.requireNonNull(context, "the context of the reconciliation must not be null");
Objects.requireNonNull(event, "event must not be null");
try {
sink.emit(toEvent(regarding, event));
sink.emit(toEvent(context, event), context);
} catch (Exception e) {
// recording an event must never break the caller: a controller that fails to reconcile
// because it could not write an event is strictly worse than one that records nothing
log.warn(
"Could not record {} event with reason {} for resource {} in namespace {}",
event.type(),
event.reason(),
regarding.getMetadata().getName(),
regarding.getMetadata().getNamespace(),
context.getPrimaryResource().getMetadata().getName(),
context.getPrimaryResource().getMetadata().getNamespace(),
Comment thread
csviri marked this conversation as resolved.
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);
public ResourceEventRecorder forContext(Context<?> context) {
Objects.requireNonNull(context, "the context events will be recorded from must not be null");
return new BoundEventRecorder(this, context);
}

protected Event toEvent(HasMetadata regarding, EventRecord record) {
protected Event toEvent(Context<?> context, EventRecord record) {
var controllerName = context.getControllerConfiguration().getName();
var regarding = context.getPrimaryResource();
var now = Instant.now().truncatedTo(ChronoUnit.SECONDS).toString();
var involvedObject = objectReferenceFor(regarding);
var builder =
new EventBuilder()
.withNewMetadata()
.withName(eventName(regarding, record))
.withNamespace(eventNamespace(regarding))
.withName(eventName(regarding, record, controllerName))
.withNamespace(eventNamespace(regarding, context))
.withLabels(record.labels())
.withAnnotations(record.annotations())
.endMetadata()
Expand All @@ -152,19 +152,33 @@ protected Event toEvent(HasMetadata regarding, EventRecord record) {
.withFirstTimestamp(now)
.withLastTimestamp(now)
.withCount(1)
.withReportingComponent(record.reportingComponent().orElse(reportingController))
.withReportingInstance(reportingInstance)
.withReportingComponent(record.reportingComponent().orElse(controllerName))
.withReportingInstance(
context
.getControllerConfiguration()
.getConfigurationService()
.getLeaderElectionConfiguration()
.flatMap(LeaderElectionConfiguration::getIdentity)
.orElseGet(DefaultEventRecorder::defaultReportingInstance))
// the deprecated source is still what kubectl renders in the "From" column
.withNewSource()
.withComponent(record.reportingComponent().orElse(reportingController))
.withComponent(record.reportingComponent().orElse(controllerName))
.endSource();
record.action().ifPresent(builder::withAction);
return builder.build();
}

private String eventNamespace(HasMetadata regarding) {
private String eventNamespace(HasMetadata regarding, Context<?> context) {
var namespace = regarding.getMetadata().getNamespace();
return namespace == null ? clusterScopedEventNamespace : namespace;
if (namespace != null) {
return namespace;
}
return requireNonNullElse(
context
.getControllerConfiguration()
.getConfigurationService()
.clusterScopedEventNamespace(),
CLUSTER_SCOPED_EVENT_NAMESPACE);
}

/**
Expand All @@ -177,7 +191,7 @@ private String eventNamespace(HasMetadata regarding) {
* <p>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, EventRecord record) {
private String eventName(HasMetadata regarding, EventRecord record, String reportingController) {
var metadata = regarding.getMetadata();
var identity =
String.join(
Expand Down Expand Up @@ -234,22 +248,22 @@ private ObjectReference objectReferenceFor(HasMetadata resource) {
.build();
}

private record BoundEventRecorder(EventRecorder delegate, HasMetadata regarding)
private record BoundEventRecorder(EventRecorder delegate, Context<?> context)
implements ResourceEventRecorder {

@Override
public void normal(String reason, String message) {
record(EventRecord.normal(reason, message));
delegate.record(EventRecord.normal(reason, message), context);
}

@Override
public void warn(String reason, String message) {
record(EventRecord.warning(reason, message));
delegate.record(EventRecord.warning(reason, message), context);
}

@Override
public void record(EventRecord event) {
delegate.record(regarding, event);
delegate.record(event, context);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import io.fabric8.kubernetes.api.model.Event;
import io.fabric8.kubernetes.api.model.EventBuilder;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.javaoperatorsdk.operator.api.reconciler.Context;

import static java.util.Objects.requireNonNullElse;

Expand Down Expand Up @@ -47,7 +48,7 @@ public DefaultEventSink(KubernetesClient client) {
}

@Override
public void emit(Event event) {
public void emit(Event event, Context<?> context) {
var events = client.v1().events().inNamespace(event.getMetadata().getNamespace());
var name = event.getMetadata().getName();
var existing = events.withName(name).get();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,38 +15,46 @@
*/
package io.javaoperatorsdk.operator.api.event;

import io.fabric8.kubernetes.api.model.HasMetadata;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.api.reconciler.Experimental;

import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE;

/**
* Records Kubernetes events on behalf of a controller.
*
* <p>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.
* <p>This is the unbound form of the API: an instance is shared by all the controllers of the
* operator, and everything that varies between them - the primary resource an event is about, the
* controller the event is attributed to, and the configuration the event is assembled from - is
* passed per call, as the {@link Context} of the reconciliation recording the event.
* Implementations are therefore expected to be stateless and thread safe. To record events through
* an implementation of your own, see {@link
* io.javaoperatorsdk.operator.api.config.ConfigurationService#eventRecorder()}. Within a
* reconciliation, prefer {@link
* io.javaoperatorsdk.operator.api.reconciler.Context#eventRecorder()}, which is already bound to
* the context.
*
* <p>Recording an event is best effort: failures to write the event to the cluster are logged and
* swallowed, and never fail the caller.
*/
@Experimental(API_MIGHT_CHANGE)
public interface EventRecorder {

/**
* Records an event about the given object.
* Records an event about the primary resource of the given reconciliation.
*
* @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
* @param context the context of the reconciliation recording the event; the event is about its
* primary resource and is attributed to its controller
*/
void record(HasMetadata regarding, EventRecord event);
void record(EventRecord event, Context<?> context);

/**
* Returns a view of this recorder bound to the given object, so that the object doesn't have to
* be passed for every event.
* Returns a view of this recorder bound to the given reconciliation, so that the context 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}
* @param context the context subsequent events will be recorded from
* @return a recorder bound to {@code context}
*/
ResourceEventRecorder forResource(HasMetadata regarding);
ResourceEventRecorder forContext(Context<?> context);
}
Loading
Loading