diff --git a/bootstrapper-maven-plugin/pom.xml b/bootstrapper-maven-plugin/pom.xml
index 75c1ebd50d..bcd6c2e743 100644
--- a/bootstrapper-maven-plugin/pom.xml
+++ b/bootstrapper-maven-plugin/pom.xml
@@ -22,7 +22,7 @@
io.javaoperatorsdkjava-operator-sdk
- 5.6.1-SNAPSHOT
+ 999-SNAPSHOTbootstrapper
diff --git a/caffeine-bounded-cache-support/pom.xml b/caffeine-bounded-cache-support/pom.xml
index 8ae3911352..be70ab9a2e 100644
--- a/caffeine-bounded-cache-support/pom.xml
+++ b/caffeine-bounded-cache-support/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdkjava-operator-sdk
- 5.6.1-SNAPSHOT
+ 999-SNAPSHOTcaffeine-bounded-cache-support
diff --git a/docs/content/en/docs/documentation/dependent-resource-and-workflows/dependent-resources.md b/docs/content/en/docs/documentation/dependent-resource-and-workflows/dependent-resources.md
index 538c52c00c..3ac1e88e71 100644
--- a/docs/content/en/docs/documentation/dependent-resource-and-workflows/dependent-resources.md
+++ b/docs/content/en/docs/documentation/dependent-resource-and-workflows/dependent-resources.md
@@ -531,6 +531,34 @@ samples [here](https://github.com/java-operator-sdk/java-operator-sdk/tree/main/
in [related integration test](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/orderedmanageddependent/ConfigMapDependentResource2.java)
.
+### Adding Common Metadata to All Managed Resources (Desired State Aspects)
+
+Operators often need to mark every resource they manage in a uniform way, for example with a
+`app.kubernetes.io/managed-by` label, so that these resources can easily be identified, selected or
+garbage-collected later on. Instead of repeating that logic in every `desired()` implementation, a
+`DesiredStateAspect` can be registered once, at the operator level, and is then applied to the
+desired state of every Kubernetes dependent resource managed by the operator:
+
+```java
+Operator operator = new Operator(overrider -> overrider
+ .withDesiredStateAspects(List.of(
+ (desired, dependentResource, context) -> desired.getMetadata().getLabels()
+ .put("app.kubernetes.io/managed-by", "my-operator"))));
+```
+
+Aspects are applied, in registration order, right after the desired state has been computed and
+before the desired state is matched against the actual resource, created or updated. As a
+consequence, the metadata added by an aspect is part of the desired state proper: if it is removed
+from the actual resource, or if the aspect itself changes, the associated secondary resources are
+updated accordingly on the next reconciliation.
+
+Since the desired state is computed at most once per reconciliation and cached in the `Context`,
+aspects are called at most once per dependent resource and reconciliation. They are only called for
+dependent resources whose desired state is a `HasMetadata`, meaning that external (non-Kubernetes)
+dependent resources are left untouched. Implementations are expected to modify the provided desired
+state in place and need to be thread-safe as they can be called concurrently for different primary
+resources.
+
## "Read-only" Dependent Resources vs. Event Source
See Integration test for a read-only
diff --git a/micrometer-support/pom.xml b/micrometer-support/pom.xml
index be864bff8e..ae3c4d0be1 100644
--- a/micrometer-support/pom.xml
+++ b/micrometer-support/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdkjava-operator-sdk
- 5.6.1-SNAPSHOT
+ 999-SNAPSHOTmicrometer-support
diff --git a/migration/pom.xml b/migration/pom.xml
index bdbc8fbc4f..cf5143c925 100644
--- a/migration/pom.xml
+++ b/migration/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdkjava-operator-sdk
- 5.6.1-SNAPSHOT
+ 999-SNAPSHOTmigration
diff --git a/operator-framework-bom/pom.xml b/operator-framework-bom/pom.xml
index 5adbefd8d8..0f974400b1 100644
--- a/operator-framework-bom/pom.xml
+++ b/operator-framework-bom/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdkoperator-framework-bom
- 5.6.1-SNAPSHOT
+ 999-SNAPSHOTpomOperator SDK - Bill of MaterialsJava SDK for implementing Kubernetes operators
diff --git a/operator-framework-core/pom.xml b/operator-framework-core/pom.xml
index b0ef5cac6f..a7d06ebdc1 100644
--- a/operator-framework-core/pom.xml
+++ b/operator-framework-core/pom.xml
@@ -21,7 +21,7 @@
io.javaoperatorsdkjava-operator-sdk
- 5.6.1-SNAPSHOT
+ 999-SNAPSHOT../pom.xml
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 db1b9a5fa5..35f46e5019 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
@@ -16,6 +16,7 @@
package io.javaoperatorsdk.operator.api.config;
import java.time.Duration;
+import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutorService;
@@ -41,6 +42,7 @@
import io.javaoperatorsdk.operator.api.reconciler.Experimental;
import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.DesiredStateAspect;
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource;
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResourceConfig;
@@ -539,4 +541,18 @@ default InformerPool informerPool() {
pool.setConfigurationService(this);
return pool;
}
+
+ /**
+ * Retrieves the {@link DesiredStateAspect}s applied to the desired state of all the Kubernetes
+ * dependent resources managed by the operator. Aspects are applied in the order in which they are
+ * returned, right after the desired state has been computed, and are typically used to add common
+ * metadata (such as a label identifying the operator managing the resource) to all the resources
+ * the operator creates or updates.
+ *
+ * @return the list of aspects to apply to computed desired states, empty by default
+ * @since 5.6.0
+ */
+ default List desiredStateAspects() {
+ return List.of();
+ }
}
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 18a2e3fc38..2cf6540af0 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
@@ -16,6 +16,8 @@
package io.javaoperatorsdk.operator.api.config;
import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutorService;
@@ -31,6 +33,7 @@
import io.javaoperatorsdk.operator.api.monitoring.Metrics;
import io.javaoperatorsdk.operator.api.reconciler.Experimental;
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory;
+import io.javaoperatorsdk.operator.api.reconciler.dependent.DesiredStateAspect;
import io.javaoperatorsdk.operator.processing.event.source.informer.pool.InformerPool;
@SuppressWarnings({"unused", "UnusedReturnValue"})
@@ -59,6 +62,7 @@ public class ConfigurationServiceOverrider {
private Boolean useSSAToPatchPrimaryResource;
private Boolean cloneSecondaryResourcesWhenGettingFromCache;
private InformerPool informerPool;
+ private List desiredStateAspects;
@SuppressWarnings("rawtypes")
private DependentResourceFactory dependentResourceFactory;
@@ -229,6 +233,38 @@ public ConfigurationServiceOverrider withInformerPool(InformerPool informerPool)
return this;
}
+ /**
+ * Replaces the {@link DesiredStateAspect}s applied to the desired state of all the Kubernetes
+ * dependent resources managed by the operator by the specified ones.
+ *
+ * @param desiredStateAspects the aspects to apply, in the order in which they should be applied
+ * @return this {@link ConfigurationServiceOverrider} for chained customization
+ * @since 5.6.0
+ */
+ public ConfigurationServiceOverrider withDesiredStateAspects(
+ List desiredStateAspects) {
+ this.desiredStateAspects = new ArrayList<>(desiredStateAspects);
+ return this;
+ }
+
+ /**
+ * Appends the specified {@link DesiredStateAspect}s to the already configured ones, which are the
+ * ones configured on the overridden {@link ConfigurationService} unless {@link
+ * #withDesiredStateAspects(List)} was called on this overrider first.
+ *
+ * @param desiredStateAspects the aspects to append, in the order in which they should be applied
+ * @return this {@link ConfigurationServiceOverrider} for chained customization
+ * @since 5.6.0
+ */
+ public ConfigurationServiceOverrider addDesiredStateAspects(
+ DesiredStateAspect... desiredStateAspects) {
+ if (this.desiredStateAspects == null) {
+ this.desiredStateAspects = new ArrayList<>(original.desiredStateAspects());
+ }
+ this.desiredStateAspects.addAll(List.of(desiredStateAspects));
+ return this;
+ }
+
public ConfigurationService build() {
return new BaseConfigurationService(original.getVersion(), cloner, client) {
@Override
@@ -383,6 +419,12 @@ public synchronized InformerPool informerPool() {
informerPool.setConfigurationService(this);
return informerPool;
}
+
+ @Override
+ public List desiredStateAspects() {
+ return overriddenValueOrDefault(
+ desiredStateAspects, ConfigurationService::desiredStateAspects);
+ }
};
}
}
diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/DefaultEventRecorder.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/DefaultEventRecorder.java
index 2e7023623d..67764b7146 100644
--- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/DefaultEventRecorder.java
+++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/event/DefaultEventRecorder.java
@@ -24,6 +24,7 @@
import java.time.temporal.ChronoUnit;
import java.util.HexFormat;
import java.util.Objects;
+import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -48,10 +49,11 @@
* 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. Repeat occurrences are then counted on that event rather than recorded as copies
- * of it, see {@link DefaultEventSink}.
+ *
By default, 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. Repeat occurrences are then counted on that event rather than
+ * recorded as copies of it, see {@link DefaultEventSink}. How events aggregate, how they are named
+ * and whether they carry an owner reference can be configured, see {@link #builder(EventSink)}.
*/
public class DefaultEventRecorder implements EventRecorder {
@@ -73,10 +75,79 @@ public class DefaultEventRecorder implements EventRecorder {
private static final int IDENTITY_HASH_LENGTH = 32;
+ /** What the API server accepts as an object name, see RFC 1123 on DNS subdomains. */
+ private static final Pattern RFC_1123_SUBDOMAIN =
+ Pattern.compile("[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*");
+
private final EventSink sink;
+ private final EventNamingStrategy namingStrategy;
+ private final EventKeyStrategy keyStrategy;
+ private final boolean ownerReference;
public DefaultEventRecorder(EventSink sink) {
+ this(sink, EventNamingStrategy.none(), EventKeyStrategy.none(), false);
+ }
+
+ private DefaultEventRecorder(
+ EventSink sink,
+ EventNamingStrategy namingStrategy,
+ EventKeyStrategy keyStrategy,
+ boolean ownerReference) {
this.sink = sink;
+ this.namingStrategy = namingStrategy;
+ this.keyStrategy = keyStrategy;
+ this.ownerReference = ownerReference;
+ }
+
+ public static Builder builder(EventSink sink) {
+ return new Builder(sink);
+ }
+
+ /** Builder for {@link DefaultEventRecorder}. */
+ public static final class Builder {
+
+ private final EventSink sink;
+ private EventNamingStrategy namingStrategy = EventNamingStrategy.none();
+ private EventKeyStrategy keyStrategy = EventKeyStrategy.none();
+ private boolean ownerReference = false;
+
+ private Builder(EventSink sink) {
+ this.sink = Objects.requireNonNull(sink, "sink must not be null");
+ }
+
+ /** The strategy naming recorded events, see {@link EventNamingStrategy}. */
+ public Builder namingStrategy(EventNamingStrategy namingStrategy) {
+ this.namingStrategy =
+ Objects.requireNonNull(namingStrategy, "namingStrategy must not be null");
+ return this;
+ }
+
+ /**
+ * The strategy deriving the default aggregation key of records that do not set one, see {@link
+ * EventKeyStrategy}. The key is ignored for events whose name is set by the record or resolved
+ * by the naming strategy, see {@link EventNamingStrategy}.
+ */
+ public Builder keyStrategy(EventKeyStrategy keyStrategy) {
+ this.keyStrategy = Objects.requireNonNull(keyStrategy, "keyStrategy must not be null");
+ return this;
+ }
+
+ /**
+ * When set, recorded events carry an {@code ownerReference} to the object they are about. The
+ * reference expresses ownership for tooling that reads it; note that the Kubernetes garbage
+ * collector ignores events, so it does not cause cascade deletion, events expire through the
+ * event TTL either way. Records can override this per event via {@link
+ * EventRecord.Builder#ownedByRegarding(boolean)}. The reference is only set when the object
+ * already has a uid.
+ */
+ public Builder ownerReference(boolean ownerReference) {
+ this.ownerReference = ownerReference;
+ return this;
+ }
+
+ public DefaultEventRecorder build() {
+ return new DefaultEventRecorder(sink, namingStrategy, keyStrategy, ownerReference);
+ }
}
/**
@@ -111,14 +182,17 @@ private static String resolve() {
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");
+ Event assembled = null;
try {
- sink.emit(toEvent(context, event), context);
+ assembled = toEvent(context, event);
+ sink.emit(assembled, 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 {}",
+ "Could not record {} event named {} with reason {} for resource {} in namespace {}",
event.type(),
+ assembled != null ? assembled.getMetadata().getName() : "unknown",
event.reason(),
context.getPrimaryResource().getMetadata().getName(),
context.getPrimaryResource().getMetadata().getNamespace(),
@@ -164,6 +238,23 @@ protected Event toEvent(Context> context, EventRecord record) {
.withNewSource()
.withComponent(record.reportingComponent().orElse(controllerName))
.endSource();
+ boolean ownedByRegarding = record.ownedByRegarding().orElse(ownerReference);
+ if (ownedByRegarding && regarding.getMetadata().getUid() == null) {
+ log.debug(
+ "Not setting the owner reference on the event about {}: the object has no uid yet",
+ regarding.getMetadata().getName());
+ }
+ if (ownedByRegarding && regarding.getMetadata().getUid() != null) {
+ builder
+ .editMetadata()
+ .addNewOwnerReference()
+ .withApiVersion(regarding.getApiVersion())
+ .withKind(regarding.getKind())
+ .withName(regarding.getMetadata().getName())
+ .withUid(regarding.getMetadata().getUid())
+ .endOwnerReference()
+ .endMetadata();
+ }
record.action().ifPresent(builder::withAction);
return builder.build();
}
@@ -181,17 +272,56 @@ private String eventNamespace(HasMetadata regarding, Context> context) {
CLUSTER_SCOPED_EVENT_NAMESPACE);
}
+ private String eventName(HasMetadata regarding, EventRecord record, String reportingController) {
+ return record
+ .name()
+ .filter(name -> !name.isBlank())
+ .or(() -> namingStrategy.nameFor(regarding, record).filter(name -> !name.isBlank()))
+ .map(DefaultEventRecorder::truncateToMaxNameLength)
+ .filter(DefaultEventRecorder::isValidEventName)
+ .orElseGet(() -> identityHashName(regarding, record, reportingController));
+ }
+
+ private static boolean isValidEventName(String name) {
+ if (RFC_1123_SUBDOMAIN.matcher(name).matches()) {
+ return true;
+ }
+ log.warn(
+ "Falling back to the default event name: {} is not a valid RFC 1123 DNS subdomain", name);
+ return false;
+ }
+
+ private static String truncateToMaxNameLength(String name) {
+ if (name.length() <= MAX_NAME_LENGTH) {
+ return name;
+ }
+ var truncated = name.substring(0, MAX_NAME_LENGTH);
+ while (truncated.endsWith("-") || truncated.endsWith(".")) {
+ truncated = truncated.substring(0, truncated.length() - 1);
+ }
+ log.warn(
+ "Truncated the name of event {} to {} to stay within the Kubernetes name limit",
+ name,
+ truncated);
+ return truncated;
+ }
+
/**
* Names events {@code