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-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..33bf4e7c49 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/DefaultEventRecorder.java @@ -0,0 +1,254 @@ +/* + * 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.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; +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; + +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 + * 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()}. + * + *

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 { + + 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; + + /** 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; + 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, record)) + .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 {@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, 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 = "." + identityDigest(identity); + var prefix = metadata.getName(); + var maxPrefixLength = MAX_NAME_LENGTH - suffix.length(); + if (prefix.length() > maxPrefixLength) { + prefix = prefix.substring(0, maxPrefixLength); + } + 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()) + .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..6b57052a8b --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/DefaultEventSink.java @@ -0,0 +1,46 @@ +/* + * 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/events/EventRecord.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventRecord.java new file mode 100644 index 0000000000..0c0cf7bcd8 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/events/EventRecord.java @@ -0,0 +1,188 @@ +/* + * 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 key; + 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.key = builder.key; + 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; + } + + /** + * 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. + */ + 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 key; + 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; + } + + /** 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; + } + + 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..051eb56b36 --- /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. + * + * @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..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 @@ -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,19 @@ 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 + * @throws UnsupportedOperationException if the implementation does not provide an event recorder + */ + 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(); 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..c1571636ba --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/events/DefaultEventRecorderTest.java @@ -0,0 +1,230 @@ +/* + * 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()); + } + + @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() + .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/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 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(); + } +}