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/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/reconciler/DefaultContext.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java index e399f7fdfd..50e61526c4 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 @@ -31,6 +31,7 @@ import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.event.ResourceEventRecorder; import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DesiredStateAspect; import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.DefaultManagedWorkflowAndDependentResourceContext; import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ManagedWorkflowAndDependentResourceContext; import io.javaoperatorsdk.operator.processing.Controller; @@ -258,6 +259,25 @@ public R getOrComputeDesiredStateFor( DependentResource dependentResource, Function desiredStateComputer) { return (R) desiredStates.computeIfAbsent( - dependentResource, ignored -> desiredStateComputer.apply(getPrimaryResource())); + dependentResource, + ignored -> { + final var desired = desiredStateComputer.apply(getPrimaryResource()); + applyDesiredStateAspects(desired, dependentResource); + return desired; + }); + } + + /** + * Applies the globally configured {@link DesiredStateAspect}s, in configuration order, to the + * freshly computed desired state. Aspects only apply to Kubernetes resources, external dependent + * resources are therefore left untouched. + */ + private void applyDesiredStateAspects(Object desired, DependentResource dependentResource) { + if (desired instanceof HasMetadata hasMetadata) { + controllerConfiguration + .getConfigurationService() + .desiredStateAspects() + .forEach(aspect -> aspect.apply(hasMetadata, dependentResource, this)); + } } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/DesiredStateAspect.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/DesiredStateAspect.java new file mode 100644 index 0000000000..826a15cb66 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/dependent/DesiredStateAspect.java @@ -0,0 +1,55 @@ +/* + * 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.reconciler.dependent; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +/** + * A cross-cutting hook applied to the desired state of every Kubernetes {@link DependentResource} + * managed by the operator, typically used to add common metadata (labels or annotations) marking + * the resources the operator manages. + * + *

Aspects are registered globally on the {@link ConfigurationService} and are applied, in + * registration order, right after the desired state has been computed and before it is matched + * against, created or updated. This means modifications performed by an aspect are taken into + * account when determining whether the actual resource matches its desired state, so that changing + * an aspect triggers an update of the associated secondary resources. + * + *

The desired state is computed at most once per reconciliation and cached in the {@link + * Context}, so aspects are also called at most once per dependent resource and reconciliation. + * Aspects are only applied to dependent resources whose desired state is a {@link HasMetadata}, + * i.e. they are not called for external (non-Kubernetes) dependent resources. + * + *

Implementations are expected to mutate the provided desired state in place and must be + * thread-safe as they can be called concurrently for different primary resources. + * + * @see ConfigurationService#desiredStateAspects() + */ +@FunctionalInterface +public interface DesiredStateAspect { + + /** + * Applies this aspect to the specified, freshly computed desired state. + * + * @param desired the desired state to modify in place + * @param dependentResource the {@link DependentResource} the desired state was computed for + * @param context the {@link Context} of the current reconciliation, from which the primary + * resource can be retrieved using {@link Context#getPrimaryResource()} + */ + void apply(HasMetadata desired, DependentResource dependentResource, Context context); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceReconciler.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceReconciler.java index 827961b77f..2428eeba96 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceReconciler.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceReconciler.java @@ -117,7 +117,10 @@ public R update(R actual, R desired, P primary, Context

context) { @Override public Result match(R resource, P primary, Context

context) { - return bulkDependentResource.match(resource, desired, primary, context); + // retrieve the desired state via the context so that it is processed the same way as for + // non-bulk dependents, in particular so that configured DesiredStateAspects are applied + // before matching + return bulkDependentResource.match(resource, getOrComputeDesired(context), primary, context); } @Override diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverriderTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverriderTest.java index 4356395618..aec8381135 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverriderTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverriderTest.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.Executors; @@ -33,6 +34,7 @@ import io.javaoperatorsdk.operator.api.monitoring.Metrics; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DesiredStateAspect; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -187,4 +189,36 @@ void clusterScopedEventNamespaceDefaultsToTheDefaultNamespaceAndCanBeOverridden( .clusterScopedEventNamespace()) .isEqualTo("operator-ns"); } + + @Test + void desiredStateAspectsAreEmptyByDefaultAndCanBeOverridden() { + assertThat(config.desiredStateAspects()).isEmpty(); + + final DesiredStateAspect first = (desired, dependentResource, context) -> {}; + final DesiredStateAspect second = (desired, dependentResource, context) -> {}; + + assertThat( + new ConfigurationServiceOverrider(config) + .withDesiredStateAspects(List.of(first, second)) + .build() + .desiredStateAspects()) + .containsExactly(first, second); + } + + @Test + void desiredStateAspectsCanBeAppendedToAlreadyConfiguredOnes() { + final DesiredStateAspect first = (desired, dependentResource, context) -> {}; + final DesiredStateAspect second = (desired, dependentResource, context) -> {}; + final DesiredStateAspect third = (desired, dependentResource, context) -> {}; + + final var configWithAspect = + new ConfigurationServiceOverrider(config).withDesiredStateAspects(List.of(first)).build(); + + assertThat( + new ConfigurationServiceOverrider(configWithAspect) + .addDesiredStateAspects(second, third) + .build() + .desiredStateAspects()) + .containsExactly(first, second, third); + } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResourceTest.java index 1db69a1f9e..7c36aabe66 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResourceTest.java @@ -15,6 +15,7 @@ */ package io.javaoperatorsdk.operator.processing.dependent; +import java.util.List; import java.util.Optional; import java.util.Set; @@ -23,8 +24,12 @@ import io.fabric8.kubernetes.api.model.ConfigMap; import io.fabric8.kubernetes.api.model.ConfigMapBuilder; import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.DefaultContext; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DesiredStateAspect; +import io.javaoperatorsdk.operator.processing.Controller; import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; import static org.junit.jupiter.api.Assertions.*; @@ -37,7 +42,18 @@ class AbstractDependentResourceTest { private static final DefaultContext CONTEXT = createContext(PRIMARY); private static DefaultContext createContext(TestCustomResource primary) { - return new DefaultContext<>(mock(), mock(), primary, false, false); + return createContext(primary, List.of()); + } + + private static DefaultContext createContext( + TestCustomResource primary, List aspects) { + final ConfigurationService configurationService = mock(); + when(configurationService.desiredStateAspects()).thenReturn(aspects); + final ControllerConfiguration controllerConfiguration = mock(); + when(controllerConfiguration.getConfigurationService()).thenReturn(configurationService); + final Controller controller = mock(); + when(controller.getConfiguration()).thenReturn(controllerConfiguration); + return new DefaultContext<>(mock(), controller, primary, false, false); } @Test @@ -101,6 +117,35 @@ void checkThatDesiredIsOnlyCalledOnce() { assertEquals(1, testDependentResource.desiredCallCount); } + @Test + void appliesConfiguredDesiredStateAspectsInOrderAndOnlyOnce() { + final var testDependentResource = new DesiredCallCountCheckingDR(); + final var primary = new TestCustomResource(); + final var spec = primary.getSpec(); + spec.setConfigMapName("foo"); + spec.setKey("key"); + spec.setValue("value"); + final var context = + createContext( + primary, + List.of( + (desired, dependentResource, ctx) -> { + assertSame(testDependentResource, dependentResource); + assertSame(primary, ctx.getPrimaryResource()); + desired.getMetadata().getLabels().put("aspect", "first"); + }, + (desired, dependentResource, ctx) -> + desired.getMetadata().getLabels().put("aspect", "second"))); + + final var created = testDependentResource.reconcile(primary, context).getSingleResource(); + assertEquals("second", created.orElseThrow().getMetadata().getLabels().get("aspect")); + + // desired state is cached, aspects should therefore not be applied again + created.orElseThrow().getMetadata().getLabels().remove("aspect"); + testDependentResource.reconcile(primary, context); + assertNull(created.orElseThrow().getMetadata().getLabels().get("aspect")); + } + private ConfigMap configMap() { ConfigMap configMap = new ConfigMap(); configMap.setMetadata( diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcherTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcherTest.java index 0c2583d594..51bba5c76d 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcherTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcherTest.java @@ -15,6 +15,7 @@ */ package io.javaoperatorsdk.operator.processing.dependent.kubernetes; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -27,12 +28,16 @@ import io.fabric8.kubernetes.client.KubernetesClient; import io.javaoperatorsdk.operator.MockKubernetesClient; import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.DefaultContext; +import io.javaoperatorsdk.operator.processing.Controller; import static io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesResourceMatcher.match; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; @SuppressWarnings({"unchecked"}) class GenericKubernetesResourceMatcherTest { @@ -47,7 +52,18 @@ public TestContext() { } public TestContext(HasMetadata primary) { - super(mock(), mock(), primary, false, false); + super(mock(), mockController(), primary, false, false); + } + + @SuppressWarnings("rawtypes") + private static Controller mockController() { + final ConfigurationService configurationService = mock(); + when(configurationService.desiredStateAspects()).thenReturn(List.of()); + final ControllerConfiguration controllerConfiguration = mock(); + when(controllerConfiguration.getConfigurationService()).thenReturn(configurationService); + final Controller controller = mock(); + when(controller.getConfiguration()).thenReturn(controllerConfiguration); + return controller; } @Override diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/desiredstateaspect/DesiredStateAspectCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/desiredstateaspect/DesiredStateAspectCustomResource.java new file mode 100644 index 0000000000..e5be695160 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/desiredstateaspect/DesiredStateAspectCustomResource.java @@ -0,0 +1,26 @@ +/* + * 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.dependent.desiredstateaspect; + +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.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +public class DesiredStateAspectCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/desiredstateaspect/DesiredStateAspectIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/desiredstateaspect/DesiredStateAspectIT.java new file mode 100644 index 0000000000..ef8048b47d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/desiredstateaspect/DesiredStateAspectIT.java @@ -0,0 +1,117 @@ +/* + * 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.dependent.desiredstateaspect; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ConfigMap; +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 = "Common metadata on all managed resources using desired state aspects", + description = + """ + Demonstrates how to register a global DesiredStateAspect on the ConfigurationService in \ + order to add common metadata, here labels identifying the operator and the dependent \ + resource the secondary resource originates from, to every Kubernetes resource managed by \ + the operator. Aspects are applied to the desired state right after it is computed, so the \ + added metadata is also taken into account when matching the actual resource against its \ + desired state. + """) +class DesiredStateAspectIT { + + public static final String TEST_RESOURCE_NAME = "test1"; + public static final String MANAGED_BY_LABEL_KEY = "app.kubernetes.io/managed-by"; + public static final String MANAGED_BY_LABEL_VALUE = "desired-state-aspect-operator"; + public static final String DEPENDENT_LABEL_KEY = "javaoperatorsdk.io/dependent"; + public static final String DEPENDENT_LABEL_VALUE = + DesiredStateAspectReconciler.ConfigMapDependentResource.class.getSimpleName().toLowerCase(); + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(DesiredStateAspectReconciler.class) + .withConfigurationService( + o -> + o.addDesiredStateAspects( + (desired, dependentResource, context) -> + desired + .getMetadata() + .getLabels() + .put(MANAGED_BY_LABEL_KEY, MANAGED_BY_LABEL_VALUE), + (desired, dependentResource, context) -> + desired + .getMetadata() + .getLabels() + .put( + DEPENDENT_LABEL_KEY, + dependentResource.getClass().getSimpleName().toLowerCase()))) + .build(); + + @Test + void aspectsAreAppliedToAllManagedResources() { + operator.create(testResource()); + + await() + .untilAsserted( + () -> { + var configMap = operator.get(ConfigMap.class, TEST_RESOURCE_NAME); + assertThat(configMap).isNotNull(); + assertThat(configMap.getMetadata().getLabels()) + .containsEntry(MANAGED_BY_LABEL_KEY, MANAGED_BY_LABEL_VALUE) + .containsEntry(DEPENDENT_LABEL_KEY, DEPENDENT_LABEL_VALUE); + }); + } + + @Test + void metadataAddedByAspectsIsRestoredIfRemoved() { + operator.create(testResource()); + + await() + .untilAsserted( + () -> + assertThat(operator.get(ConfigMap.class, TEST_RESOURCE_NAME)) + .isNotNull() + .extracting(cm -> cm.getMetadata().getLabels()) + .satisfies( + labels -> + assertThat(labels) + .containsEntry(MANAGED_BY_LABEL_KEY, MANAGED_BY_LABEL_VALUE))); + + var configMap = operator.get(ConfigMap.class, TEST_RESOURCE_NAME); + configMap.getMetadata().getLabels().remove(MANAGED_BY_LABEL_KEY); + operator.replace(configMap); + + await() + .untilAsserted( + () -> + assertThat( + operator.get(ConfigMap.class, TEST_RESOURCE_NAME).getMetadata().getLabels()) + .containsEntry(MANAGED_BY_LABEL_KEY, MANAGED_BY_LABEL_VALUE)); + } + + DesiredStateAspectCustomResource testResource() { + var res = new DesiredStateAspectCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/desiredstateaspect/DesiredStateAspectReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/desiredstateaspect/DesiredStateAspectReconciler.java new file mode 100644 index 0000000000..bdb34435e3 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/desiredstateaspect/DesiredStateAspectReconciler.java @@ -0,0 +1,59 @@ +/* + * 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.dependent.desiredstateaspect; + +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +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; +import io.javaoperatorsdk.operator.api.reconciler.Workflow; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; + +@Workflow( + dependents = @Dependent(type = DesiredStateAspectReconciler.ConfigMapDependentResource.class)) +@ControllerConfiguration +public class DesiredStateAspectReconciler implements Reconciler { + + @Override + public UpdateControl reconcile( + DesiredStateAspectCustomResource resource, + Context context) { + return UpdateControl.noUpdate(); + } + + public static class ConfigMapDependentResource + extends CRUDKubernetesDependentResource { + + @Override + protected ConfigMap desired( + DesiredStateAspectCustomResource primary, + Context context) { + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()); + configMap.setData(Map.of("data", primary.getMetadata().getName())); + return configMap; + } + } +}