Skip to content

Commit 322d9bc

Browse files
authored
feat: add a desired state aspect (#3598)
Signed-off-by: xstefank <xstefank122@gmail.com>
1 parent 1ecd5f0 commit 322d9bc

12 files changed

Lines changed: 465 additions & 4 deletions

File tree

docs/content/en/docs/documentation/dependent-resource-and-workflows/dependent-resources.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,34 @@ samples [here](https://github.com/java-operator-sdk/java-operator-sdk/tree/main/
531531
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)
532532
.
533533

534+
### Adding Common Metadata to All Managed Resources (Desired State Aspects)
535+
536+
Operators often need to mark every resource they manage in a uniform way, for example with a
537+
`app.kubernetes.io/managed-by` label, so that these resources can easily be identified, selected or
538+
garbage-collected later on. Instead of repeating that logic in every `desired()` implementation, a
539+
`DesiredStateAspect` can be registered once, at the operator level, and is then applied to the
540+
desired state of every Kubernetes dependent resource managed by the operator:
541+
542+
```java
543+
Operator operator = new Operator(overrider -> overrider
544+
.withDesiredStateAspects(List.of(
545+
(desired, dependentResource, context) -> desired.getMetadata().getLabels()
546+
.put("app.kubernetes.io/managed-by", "my-operator"))));
547+
```
548+
549+
Aspects are applied, in registration order, right after the desired state has been computed and
550+
before the desired state is matched against the actual resource, created or updated. As a
551+
consequence, the metadata added by an aspect is part of the desired state proper: if it is removed
552+
from the actual resource, or if the aspect itself changes, the associated secondary resources are
553+
updated accordingly on the next reconciliation.
554+
555+
Since the desired state is computed at most once per reconciliation and cached in the `Context`,
556+
aspects are called at most once per dependent resource and reconciliation. They are only called for
557+
dependent resources whose desired state is a `HasMetadata`, meaning that external (non-Kubernetes)
558+
dependent resources are left untouched. Implementations are expected to modify the provided desired
559+
state in place and need to be thread-safe as they can be called concurrently for different primary
560+
resources.
561+
534562
## "Read-only" Dependent Resources vs. Event Source
535563

536564
See Integration test for a read-only

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package io.javaoperatorsdk.operator.api.config;
1717

1818
import java.time.Duration;
19+
import java.util.List;
1920
import java.util.Optional;
2021
import java.util.Set;
2122
import java.util.concurrent.ExecutorService;
@@ -41,6 +42,7 @@
4142
import io.javaoperatorsdk.operator.api.reconciler.Experimental;
4243
import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
4344
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory;
45+
import io.javaoperatorsdk.operator.api.reconciler.dependent.DesiredStateAspect;
4446
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
4547
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource;
4648
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResourceConfig;
@@ -539,4 +541,18 @@ default InformerPool informerPool() {
539541
pool.setConfigurationService(this);
540542
return pool;
541543
}
544+
545+
/**
546+
* Retrieves the {@link DesiredStateAspect}s applied to the desired state of all the Kubernetes
547+
* dependent resources managed by the operator. Aspects are applied in the order in which they are
548+
* returned, right after the desired state has been computed, and are typically used to add common
549+
* metadata (such as a label identifying the operator managing the resource) to all the resources
550+
* the operator creates or updates.
551+
*
552+
* @return the list of aspects to apply to computed desired states, empty by default
553+
* @since 5.6.0
554+
*/
555+
default List<DesiredStateAspect> desiredStateAspects() {
556+
return List.of();
557+
}
542558
}

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
package io.javaoperatorsdk.operator.api.config;
1717

1818
import java.time.Duration;
19+
import java.util.ArrayList;
20+
import java.util.List;
1921
import java.util.Optional;
2022
import java.util.Set;
2123
import java.util.concurrent.ExecutorService;
@@ -31,6 +33,7 @@
3133
import io.javaoperatorsdk.operator.api.monitoring.Metrics;
3234
import io.javaoperatorsdk.operator.api.reconciler.Experimental;
3335
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory;
36+
import io.javaoperatorsdk.operator.api.reconciler.dependent.DesiredStateAspect;
3437
import io.javaoperatorsdk.operator.processing.event.source.informer.pool.InformerPool;
3538

3639
@SuppressWarnings({"unused", "UnusedReturnValue"})
@@ -59,6 +62,7 @@ public class ConfigurationServiceOverrider {
5962
private Boolean useSSAToPatchPrimaryResource;
6063
private Boolean cloneSecondaryResourcesWhenGettingFromCache;
6164
private InformerPool informerPool;
65+
private List<DesiredStateAspect> desiredStateAspects;
6266

6367
@SuppressWarnings("rawtypes")
6468
private DependentResourceFactory dependentResourceFactory;
@@ -229,6 +233,38 @@ public ConfigurationServiceOverrider withInformerPool(InformerPool informerPool)
229233
return this;
230234
}
231235

236+
/**
237+
* Replaces the {@link DesiredStateAspect}s applied to the desired state of all the Kubernetes
238+
* dependent resources managed by the operator by the specified ones.
239+
*
240+
* @param desiredStateAspects the aspects to apply, in the order in which they should be applied
241+
* @return this {@link ConfigurationServiceOverrider} for chained customization
242+
* @since 5.6.0
243+
*/
244+
public ConfigurationServiceOverrider withDesiredStateAspects(
245+
List<DesiredStateAspect> desiredStateAspects) {
246+
this.desiredStateAspects = new ArrayList<>(desiredStateAspects);
247+
return this;
248+
}
249+
250+
/**
251+
* Appends the specified {@link DesiredStateAspect}s to the already configured ones, which are the
252+
* ones configured on the overridden {@link ConfigurationService} unless {@link
253+
* #withDesiredStateAspects(List)} was called on this overrider first.
254+
*
255+
* @param desiredStateAspects the aspects to append, in the order in which they should be applied
256+
* @return this {@link ConfigurationServiceOverrider} for chained customization
257+
* @since 5.6.0
258+
*/
259+
public ConfigurationServiceOverrider addDesiredStateAspects(
260+
DesiredStateAspect... desiredStateAspects) {
261+
if (this.desiredStateAspects == null) {
262+
this.desiredStateAspects = new ArrayList<>(original.desiredStateAspects());
263+
}
264+
this.desiredStateAspects.addAll(List.of(desiredStateAspects));
265+
return this;
266+
}
267+
232268
public ConfigurationService build() {
233269
return new BaseConfigurationService(original.getVersion(), cloner, client) {
234270
@Override
@@ -383,6 +419,12 @@ public synchronized InformerPool informerPool() {
383419
informerPool.setConfigurationService(this);
384420
return informerPool;
385421
}
422+
423+
@Override
424+
public List<DesiredStateAspect> desiredStateAspects() {
425+
return overriddenValueOrDefault(
426+
desiredStateAspects, ConfigurationService::desiredStateAspects);
427+
}
386428
};
387429
}
388430
}

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
3232
import io.javaoperatorsdk.operator.api.event.ResourceEventRecorder;
3333
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
34+
import io.javaoperatorsdk.operator.api.reconciler.dependent.DesiredStateAspect;
3435
import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.DefaultManagedWorkflowAndDependentResourceContext;
3536
import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ManagedWorkflowAndDependentResourceContext;
3637
import io.javaoperatorsdk.operator.processing.Controller;
@@ -258,6 +259,25 @@ public <R> R getOrComputeDesiredStateFor(
258259
DependentResource<R, P> dependentResource, Function<P, R> desiredStateComputer) {
259260
return (R)
260261
desiredStates.computeIfAbsent(
261-
dependentResource, ignored -> desiredStateComputer.apply(getPrimaryResource()));
262+
dependentResource,
263+
ignored -> {
264+
final var desired = desiredStateComputer.apply(getPrimaryResource());
265+
applyDesiredStateAspects(desired, dependentResource);
266+
return desired;
267+
});
268+
}
269+
270+
/**
271+
* Applies the globally configured {@link DesiredStateAspect}s, in configuration order, to the
272+
* freshly computed desired state. Aspects only apply to Kubernetes resources, external dependent
273+
* resources are therefore left untouched.
274+
*/
275+
private void applyDesiredStateAspects(Object desired, DependentResource<?, P> dependentResource) {
276+
if (desired instanceof HasMetadata hasMetadata) {
277+
controllerConfiguration
278+
.getConfigurationService()
279+
.desiredStateAspects()
280+
.forEach(aspect -> aspect.apply(hasMetadata, dependentResource, this));
281+
}
262282
}
263283
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright Java Operator SDK Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.javaoperatorsdk.operator.api.reconciler.dependent;
17+
18+
import io.fabric8.kubernetes.api.model.HasMetadata;
19+
import io.javaoperatorsdk.operator.api.config.ConfigurationService;
20+
import io.javaoperatorsdk.operator.api.reconciler.Context;
21+
22+
/**
23+
* A cross-cutting hook applied to the desired state of every Kubernetes {@link DependentResource}
24+
* managed by the operator, typically used to add common metadata (labels or annotations) marking
25+
* the resources the operator manages.
26+
*
27+
* <p>Aspects are registered globally on the {@link ConfigurationService} and are applied, in
28+
* registration order, right after the desired state has been computed and before it is matched
29+
* against, created or updated. This means modifications performed by an aspect are taken into
30+
* account when determining whether the actual resource matches its desired state, so that changing
31+
* an aspect triggers an update of the associated secondary resources.
32+
*
33+
* <p>The desired state is computed at most once per reconciliation and cached in the {@link
34+
* Context}, so aspects are also called at most once per dependent resource and reconciliation.
35+
* Aspects are only applied to dependent resources whose desired state is a {@link HasMetadata},
36+
* i.e. they are not called for external (non-Kubernetes) dependent resources.
37+
*
38+
* <p>Implementations are expected to mutate the provided desired state in place and must be
39+
* thread-safe as they can be called concurrently for different primary resources.
40+
*
41+
* @see ConfigurationService#desiredStateAspects()
42+
*/
43+
@FunctionalInterface
44+
public interface DesiredStateAspect {
45+
46+
/**
47+
* Applies this aspect to the specified, freshly computed desired state.
48+
*
49+
* @param desired the desired state to modify in place
50+
* @param dependentResource the {@link DependentResource} the desired state was computed for
51+
* @param context the {@link Context} of the current reconciliation, from which the primary
52+
* resource can be retrieved using {@link Context#getPrimaryResource()}
53+
*/
54+
void apply(HasMetadata desired, DependentResource<?, ?> dependentResource, Context<?> context);
55+
}

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/BulkDependentResourceReconciler.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,10 @@ public R update(R actual, R desired, P primary, Context<P> context) {
117117

118118
@Override
119119
public Result<R> match(R resource, P primary, Context<P> context) {
120-
return bulkDependentResource.match(resource, desired, primary, context);
120+
// retrieve the desired state via the context so that it is processed the same way as for
121+
// non-bulk dependents, in particular so that configured DesiredStateAspects are applied
122+
// before matching
123+
return bulkDependentResource.match(resource, getOrComputeDesired(context), primary, context);
121124
}
122125

123126
@Override

operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverriderTest.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package io.javaoperatorsdk.operator.api.config;
1717

1818
import java.time.Duration;
19+
import java.util.List;
1920
import java.util.Optional;
2021
import java.util.Set;
2122
import java.util.concurrent.Executors;
@@ -33,6 +34,7 @@
3334
import io.javaoperatorsdk.operator.api.monitoring.Metrics;
3435
import io.javaoperatorsdk.operator.api.reconciler.Context;
3536
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory;
37+
import io.javaoperatorsdk.operator.api.reconciler.dependent.DesiredStateAspect;
3638

3739
import static org.assertj.core.api.Assertions.assertThat;
3840
import static org.junit.jupiter.api.Assertions.assertNotEquals;
@@ -187,4 +189,36 @@ void clusterScopedEventNamespaceDefaultsToTheDefaultNamespaceAndCanBeOverridden(
187189
.clusterScopedEventNamespace())
188190
.isEqualTo("operator-ns");
189191
}
192+
193+
@Test
194+
void desiredStateAspectsAreEmptyByDefaultAndCanBeOverridden() {
195+
assertThat(config.desiredStateAspects()).isEmpty();
196+
197+
final DesiredStateAspect first = (desired, dependentResource, context) -> {};
198+
final DesiredStateAspect second = (desired, dependentResource, context) -> {};
199+
200+
assertThat(
201+
new ConfigurationServiceOverrider(config)
202+
.withDesiredStateAspects(List.of(first, second))
203+
.build()
204+
.desiredStateAspects())
205+
.containsExactly(first, second);
206+
}
207+
208+
@Test
209+
void desiredStateAspectsCanBeAppendedToAlreadyConfiguredOnes() {
210+
final DesiredStateAspect first = (desired, dependentResource, context) -> {};
211+
final DesiredStateAspect second = (desired, dependentResource, context) -> {};
212+
final DesiredStateAspect third = (desired, dependentResource, context) -> {};
213+
214+
final var configWithAspect =
215+
new ConfigurationServiceOverrider(config).withDesiredStateAspects(List.of(first)).build();
216+
217+
assertThat(
218+
new ConfigurationServiceOverrider(configWithAspect)
219+
.addDesiredStateAspects(second, third)
220+
.build()
221+
.desiredStateAspects())
222+
.containsExactly(first, second, third);
223+
}
190224
}

operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/AbstractDependentResourceTest.java

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package io.javaoperatorsdk.operator.processing.dependent;
1717

18+
import java.util.List;
1819
import java.util.Optional;
1920
import java.util.Set;
2021

@@ -23,8 +24,12 @@
2324
import io.fabric8.kubernetes.api.model.ConfigMap;
2425
import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
2526
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
27+
import io.javaoperatorsdk.operator.api.config.ConfigurationService;
28+
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
2629
import io.javaoperatorsdk.operator.api.reconciler.Context;
2730
import io.javaoperatorsdk.operator.api.reconciler.DefaultContext;
31+
import io.javaoperatorsdk.operator.api.reconciler.dependent.DesiredStateAspect;
32+
import io.javaoperatorsdk.operator.processing.Controller;
2833
import io.javaoperatorsdk.operator.sample.simple.TestCustomResource;
2934

3035
import static org.junit.jupiter.api.Assertions.*;
@@ -37,7 +42,18 @@ class AbstractDependentResourceTest {
3742
private static final DefaultContext<TestCustomResource> CONTEXT = createContext(PRIMARY);
3843

3944
private static DefaultContext<TestCustomResource> createContext(TestCustomResource primary) {
40-
return new DefaultContext<>(mock(), mock(), primary, false, false);
45+
return createContext(primary, List.of());
46+
}
47+
48+
private static DefaultContext<TestCustomResource> createContext(
49+
TestCustomResource primary, List<DesiredStateAspect> aspects) {
50+
final ConfigurationService configurationService = mock();
51+
when(configurationService.desiredStateAspects()).thenReturn(aspects);
52+
final ControllerConfiguration<TestCustomResource> controllerConfiguration = mock();
53+
when(controllerConfiguration.getConfigurationService()).thenReturn(configurationService);
54+
final Controller<TestCustomResource> controller = mock();
55+
when(controller.getConfiguration()).thenReturn(controllerConfiguration);
56+
return new DefaultContext<>(mock(), controller, primary, false, false);
4157
}
4258

4359
@Test
@@ -101,6 +117,35 @@ void checkThatDesiredIsOnlyCalledOnce() {
101117
assertEquals(1, testDependentResource.desiredCallCount);
102118
}
103119

120+
@Test
121+
void appliesConfiguredDesiredStateAspectsInOrderAndOnlyOnce() {
122+
final var testDependentResource = new DesiredCallCountCheckingDR();
123+
final var primary = new TestCustomResource();
124+
final var spec = primary.getSpec();
125+
spec.setConfigMapName("foo");
126+
spec.setKey("key");
127+
spec.setValue("value");
128+
final var context =
129+
createContext(
130+
primary,
131+
List.of(
132+
(desired, dependentResource, ctx) -> {
133+
assertSame(testDependentResource, dependentResource);
134+
assertSame(primary, ctx.getPrimaryResource());
135+
desired.getMetadata().getLabels().put("aspect", "first");
136+
},
137+
(desired, dependentResource, ctx) ->
138+
desired.getMetadata().getLabels().put("aspect", "second")));
139+
140+
final var created = testDependentResource.reconcile(primary, context).getSingleResource();
141+
assertEquals("second", created.orElseThrow().getMetadata().getLabels().get("aspect"));
142+
143+
// desired state is cached, aspects should therefore not be applied again
144+
created.orElseThrow().getMetadata().getLabels().remove("aspect");
145+
testDependentResource.reconcile(primary, context);
146+
assertNull(created.orElseThrow().getMetadata().getLabels().get("aspect"));
147+
}
148+
104149
private ConfigMap configMap() {
105150
ConfigMap configMap = new ConfigMap();
106151
configMap.setMetadata(

0 commit comments

Comments
 (0)