Skip to content

Commit d4bd3da

Browse files
hej090224claude
andauthored
feat: detect dependent resource API version changes (#3536)
Add an opt-in, experimental detectApiVersionChange option on @KubernetesDependent that records the API version the operator applies in the javaoperatorsdk.io/last-applied-api-version annotation. The regular matcher then detects a mismatch when that marker differs from (or is missing relative to) the currently applied API version, causing a one-time update without triggering repeated reconciliations once the resource is up-to-date. Disabled by default, so existing behavior and matching are unaffected unless explicitly enabled. Guard against a null or immutable annotations map (e.g. Map.of(...)) on the desired resource when detectApiVersionChange is enabled, since writing the last-applied-api-version marker (and the pre-existing previous-annotation bookkeeping that runs alongside it) requires a mutable map. Also rename a misleadingly-named test helper and replace a no-op assertion on a primitive boolean with a concrete expectation. Addresses Copilot review feedback on PR #3536. Add DetectApiVersionChangeIT covering the end-to-end scenario: a ConfigMap dependent resource configured with detectApiVersionChange is marked with the current API version on creation (without triggering an update), and a stale marker annotation left on the actual resource (simulating an older operator/CRD version) is detected and corrected with exactly one update, after which no further reconciliation loop occurs. Signed-off-by: hej090224 <fc49854985@gmail.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 592bad3 commit d4bd3da

12 files changed

Lines changed: 843 additions & 2 deletions

File tree

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,41 @@ If you encounter this issue on an older Kubernetes version, consider changing yo
289289
that resource, or even upgrading your Kubernetes version. If you encounter it on a newer Kubernetes version, please log
290290
an issue with the JOSDK and with upstream Kubernetes.
291291

292+
### Detecting dependent resource API version changes (experimental)
293+
294+
When a dependent resource's CRD gains a new API version and the operator is upgraded to target it,
295+
comparing `actualResource.getApiVersion()` with the desired resource's API version is not a
296+
reliable way to detect resources that still need to be updated: the Kubernetes API server serves a
297+
resource using the requested, served API version regardless of which version it is actually stored
298+
as, so this comparison would always trivially match.
299+
300+
`KubernetesDependentResource` therefore ignores `apiVersion` when matching. To still force a
301+
one-time update of dependent resources after such an upgrade, without triggering an update on every
302+
reconciliation, `KubernetesDependent` provides the opt-in, experimental
303+
`detectApiVersionChange` flag:
304+
305+
```java
306+
@KubernetesDependent(detectApiVersionChange = true)
307+
public class MyDependentResource extends CRUDKubernetesDependentResource<MyResource, MyPrimary> {
308+
// ...
309+
}
310+
```
311+
312+
When enabled, JOSDK records the API version it applies in the `javaoperatorsdk.io/last-applied-api-version`
313+
annotation. On subsequent reconciliations, the resource is considered mismatched (and thus updated)
314+
if that recorded marker differs from the API version the operator currently uses - this also
315+
covers resources that predate this feature and therefore have no marker at all. Once the resource
316+
has been updated, the marker matches the current API version again, so no further update is
317+
requested until the API version changes again.
318+
319+
This is disabled by default: existing behavior, including for resources created before this
320+
feature existed, is unaffected unless you opt in. It does not read or infer the actual storage
321+
version of the resource from the Kubernetes API, since that information is not reliably exposed;
322+
it only tracks what the operator itself last applied. It is also not a replacement for
323+
Kubernetes' [StorageVersionMigration](https://kubernetes.io/docs/tasks/manage-kubernetes-objects/storage-version-migration/),
324+
which addresses migrating the stored representation of resources, a concern orthogonal to this
325+
feature.
326+
292327
## Telling JOSDK how to find which secondary resources are associated with a given primary resource
293328

294329
[`KubernetesDependentResource`](https://github.com/java-operator-sdk/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java)

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
import java.lang.annotation.Target;
2222

2323
import io.javaoperatorsdk.operator.api.config.informer.Informer;
24+
import io.javaoperatorsdk.operator.api.reconciler.Experimental;
25+
26+
import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE;
2427

2528
@Retention(RetentionPolicy.RUNTIME)
2629
@Target({ElementType.TYPE})
@@ -62,4 +65,32 @@ boolean createResourceOnlyIfNotExistingWithSSA() default
6265
*/
6366
Class<? extends SSABasedGenericKubernetesResourceMatcher> matcher() default
6467
SSABasedGenericKubernetesResourceMatcher.class;
68+
69+
/**
70+
* Whether JOSDK should detect that the API version of this dependent resource's desired state has
71+
* changed since it was last applied by the operator (for example after the operator was upgraded
72+
* to target a new CRD version) and, in that case, request a one-time update of the actual
73+
* resource.
74+
*
75+
* <p>When enabled, JOSDK records the API version it applies in the {@value
76+
* KubernetesDependentResource#LAST_APPLIED_API_VERSION_ANNOTATION_KEY} annotation. On subsequent
77+
* reconciliations, the resource is considered mismatched (and thus updated) if that recorded
78+
* marker differs from the API version the operator currently uses, including when the marker is
79+
* missing entirely (for example on resources created before this feature was enabled). Once the
80+
* resource has been updated, the marker matches the current API version again, so no further
81+
* update is requested until the API version changes again.
82+
*
83+
* <p>This is opt-in and disabled by default: when disabled, no marker annotation is ever added or
84+
* read, and matching behavior is unchanged. It does not read or infer the actual storage version
85+
* of the resource in Kubernetes, since that information is not reliably exposed by the API
86+
* server; it only tracks what the operator itself last applied. It is not a replacement for
87+
* Kubernetes' <a
88+
* href="https://kubernetes.io/docs/tasks/manage-kubernetes-objects/storage-version-migration/">StorageVersionMigration</a>.
89+
*
90+
* @return {@code true} if API version change detection is enabled, {@code false} otherwise
91+
* @since 5.6
92+
*/
93+
@Experimental(API_MIGHT_CHANGE)
94+
boolean detectApiVersionChange() default
95+
KubernetesDependentResourceConfig.DEFAULT_DETECT_API_VERSION_CHANGE;
6596
}

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ public KubernetesDependentResourceConfig<R> configFrom(
3535
ControllerConfiguration<?> controllerConfig) {
3636
var createResourceOnlyIfNotExistingWithSSA =
3737
DEFAULT_CREATE_RESOURCE_ONLY_IF_NOT_EXISTING_WITH_SSA;
38+
var detectApiVersionChange =
39+
KubernetesDependentResourceConfig.DEFAULT_DETECT_API_VERSION_CHANGE;
3840

3941
Boolean useSSA = null;
4042
SSABasedGenericKubernetesResourceMatcher<R> matcher =
@@ -43,6 +45,7 @@ public KubernetesDependentResourceConfig<R> configFrom(
4345
createResourceOnlyIfNotExistingWithSSA =
4446
configAnnotation.createResourceOnlyIfNotExistingWithSSA();
4547
useSSA = configAnnotation.useSSA().asBoolean();
48+
detectApiVersionChange = configAnnotation.detectApiVersionChange();
4649

4750
// check if we have a specific matcher
4851
Class<? extends KubernetesDependentResource<?, ?>> dependentResourceClass =
@@ -62,7 +65,11 @@ public KubernetesDependentResourceConfig<R> configFrom(
6265
controllerConfig);
6366

6467
return new KubernetesDependentResourceConfig<>(
65-
useSSA, createResourceOnlyIfNotExistingWithSSA, informerConfiguration, matcher);
68+
useSSA,
69+
createResourceOnlyIfNotExistingWithSSA,
70+
informerConfiguration,
71+
matcher,
72+
detectApiVersionChange);
6673
}
6774

6875
@SuppressWarnings({"unchecked"})

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

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

18+
import java.util.LinkedHashMap;
1819
import java.util.Map;
1920
import java.util.Objects;
2021
import java.util.Optional;
@@ -52,6 +53,15 @@ public abstract class KubernetesDependentResource<R extends HasMetadata, P exten
5253

5354
private static final Logger log = LoggerFactory.getLogger(KubernetesDependentResource.class);
5455

56+
/**
57+
* Annotation used to record the API version the operator applied to this resource, when {@link
58+
* KubernetesDependentResourceConfig#detectApiVersionChange()} is enabled.
59+
*
60+
* @see KubernetesDependent#detectApiVersionChange()
61+
*/
62+
public static final String LAST_APPLIED_API_VERSION_ANNOTATION_KEY =
63+
"javaoperatorsdk.io/last-applied-api-version";
64+
5565
private final boolean garbageCollected = this instanceof GarbageCollected;
5666
private KubernetesDependentResourceConfig<R> kubernetesDependentResourceConfig;
5767
private volatile Boolean useSSA;
@@ -160,6 +170,12 @@ public Result<R> match(R actualResource, R desired, P primary, Context<P> contex
160170

161171
protected void addMetadata(
162172
boolean forMatch, R actualResource, final R target, P primary, Context<P> context) {
173+
if (kubernetesDependentResourceConfig != null
174+
&& kubernetesDependentResourceConfig.detectApiVersionChange()) {
175+
// desired resources might expose a null or immutable annotations map (e.g. Map.of(...));
176+
// make sure it's a mutable one before this method or its callees write to it
177+
ensureMutableAnnotations(target);
178+
}
163179
if (forMatch) { // keep the current previous annotation
164180
String actual =
165181
actualResource
@@ -173,9 +189,36 @@ protected void addMetadata(
173189
annotations.remove(InformerEventSource.PREVIOUS_ANNOTATION_KEY);
174190
}
175191
}
192+
addLastAppliedApiVersion(target);
176193
addReferenceHandlingMetadata(target, primary);
177194
}
178195

196+
private static void ensureMutableAnnotations(HasMetadata target) {
197+
var metadata = target.getMetadata();
198+
metadata.setAnnotations(
199+
new LinkedHashMap<>(Optional.ofNullable(metadata.getAnnotations()).orElseGet(Map::of)));
200+
}
201+
202+
/**
203+
* When {@link KubernetesDependentResourceConfig#detectApiVersionChange()} is enabled, marks the
204+
* target resource with the API version the operator is currently applying. Comparing this marker
205+
* with the one recorded on the actual resource lets the regular matching logic detect a mismatch,
206+
* without ever inspecting the actual, potentially unreliable, stored API version.
207+
*/
208+
private void addLastAppliedApiVersion(R target) {
209+
if (kubernetesDependentResourceConfig == null
210+
|| !kubernetesDependentResourceConfig.detectApiVersionChange()) {
211+
return;
212+
}
213+
var apiVersion = target.getApiVersion();
214+
if (apiVersion != null) {
215+
target
216+
.getMetadata()
217+
.getAnnotations()
218+
.put(LAST_APPLIED_API_VERSION_ANNOTATION_KEY, apiVersion);
219+
}
220+
}
221+
179222
protected boolean useSSA(Context<P> context) {
180223
if (useSSA == null) {
181224
useSSA =

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@
2121
public class KubernetesDependentResourceConfig<R extends HasMetadata> {
2222

2323
public static final boolean DEFAULT_CREATE_RESOURCE_ONLY_IF_NOT_EXISTING_WITH_SSA = true;
24+
public static final boolean DEFAULT_DETECT_API_VERSION_CHANGE = false;
2425

2526
private final Boolean useSSA;
2627
private final boolean createResourceOnlyIfNotExistingWithSSA;
2728
private final InformerConfiguration<R> informerConfig;
2829
private final SSABasedGenericKubernetesResourceMatcher<R> matcher;
30+
private final boolean detectApiVersionChange;
2931

3032
public KubernetesDependentResourceConfig(
3133
Boolean useSSA,
@@ -39,11 +41,26 @@ public KubernetesDependentResourceConfig(
3941
boolean createResourceOnlyIfNotExistingWithSSA,
4042
InformerConfiguration<R> informerConfig,
4143
SSABasedGenericKubernetesResourceMatcher<R> matcher) {
44+
this(
45+
useSSA,
46+
createResourceOnlyIfNotExistingWithSSA,
47+
informerConfig,
48+
matcher,
49+
DEFAULT_DETECT_API_VERSION_CHANGE);
50+
}
51+
52+
public KubernetesDependentResourceConfig(
53+
Boolean useSSA,
54+
boolean createResourceOnlyIfNotExistingWithSSA,
55+
InformerConfiguration<R> informerConfig,
56+
SSABasedGenericKubernetesResourceMatcher<R> matcher,
57+
boolean detectApiVersionChange) {
4258
this.useSSA = useSSA;
4359
this.createResourceOnlyIfNotExistingWithSSA = createResourceOnlyIfNotExistingWithSSA;
4460
this.informerConfig = informerConfig;
4561
this.matcher =
4662
matcher != null ? matcher : SSABasedGenericKubernetesResourceMatcher.getInstance();
63+
this.detectApiVersionChange = detectApiVersionChange;
4764
}
4865

4966
public boolean createResourceOnlyIfNotExistingWithSSA() {
@@ -61,4 +78,16 @@ public InformerConfiguration<R> informerConfig() {
6178
public SSABasedGenericKubernetesResourceMatcher<R> matcher() {
6279
return matcher;
6380
}
81+
82+
/**
83+
* Whether JOSDK should detect when the API version of this dependent resource's desired state has
84+
* changed since it was last applied by the operator and, in that case, request a one-time update
85+
* of the actual resource.
86+
*
87+
* @return {@code true} if API version change detection is enabled, {@code false} otherwise
88+
* @since 5.6
89+
*/
90+
public boolean detectApiVersionChange() {
91+
return detectApiVersionChange;
92+
}
6493
}

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ public final class KubernetesDependentResourceConfigBuilder<R extends HasMetadat
2424
private Boolean useSSA = null;
2525
private InformerConfiguration<R> informerConfiguration;
2626
private SSABasedGenericKubernetesResourceMatcher<R> matcher;
27+
private boolean detectApiVersionChange =
28+
KubernetesDependentResourceConfig.DEFAULT_DETECT_API_VERSION_CHANGE;
2729

2830
public KubernetesDependentResourceConfigBuilder() {}
2931

@@ -51,8 +53,18 @@ public KubernetesDependentResourceConfigBuilder<R> withSSAMatcher(
5153
return this;
5254
}
5355

56+
public KubernetesDependentResourceConfigBuilder<R> withDetectApiVersionChange(
57+
boolean detectApiVersionChange) {
58+
this.detectApiVersionChange = detectApiVersionChange;
59+
return this;
60+
}
61+
5462
public KubernetesDependentResourceConfig<R> build() {
5563
return new KubernetesDependentResourceConfig<>(
56-
useSSA, createResourceOnlyIfNotExistingWithSSA, informerConfiguration, matcher);
64+
useSSA,
65+
createResourceOnlyIfNotExistingWithSSA,
66+
informerConfiguration,
67+
matcher,
68+
detectApiVersionChange);
5769
}
5870
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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.processing.dependent.kubernetes;
17+
18+
import java.util.Set;
19+
20+
import org.junit.jupiter.api.Test;
21+
22+
import io.fabric8.kubernetes.api.model.ConfigMap;
23+
import io.fabric8.kubernetes.api.model.GenericKubernetesResource;
24+
import io.javaoperatorsdk.operator.api.config.ConfigurationService;
25+
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
26+
import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec;
27+
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory;
28+
import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected;
29+
30+
import static org.assertj.core.api.Assertions.assertThat;
31+
import static org.mockito.Mockito.mock;
32+
import static org.mockito.Mockito.when;
33+
34+
/**
35+
* Focused unit test for the {@code detectApiVersionChange} wiring performed by {@link
36+
* KubernetesDependentConverter}, independent of the shared, process-wide {@link
37+
* io.javaoperatorsdk.operator.api.config.dependent.DependentResourceConfigurationResolver} state
38+
* that other tests in this module mutate.
39+
*/
40+
class KubernetesDependentConverterTest {
41+
42+
private final KubernetesDependentConverter<GenericKubernetesResource, ConfigMap> converter =
43+
new KubernetesDependentConverter<>();
44+
45+
@Test
46+
void detectApiVersionChangeDefaultsToFalseWhenAnnotationAbsent() {
47+
var config =
48+
converter.configFrom(null, spec(PlainWidgetDependentResource.class), controllerConfig());
49+
50+
assertThat(config.detectApiVersionChange()).isFalse();
51+
}
52+
53+
@Test
54+
void detectApiVersionChangeDefaultsToFalseWhenNotSetOnAnnotation() {
55+
var annotation = PlainWidgetDependentResource.class.getAnnotation(KubernetesDependent.class);
56+
var config =
57+
converter.configFrom(
58+
annotation, spec(PlainWidgetDependentResource.class), controllerConfig());
59+
60+
assertThat(config.detectApiVersionChange()).isFalse();
61+
}
62+
63+
@Test
64+
void detectApiVersionChangeCanBeEnabledViaAnnotation() {
65+
var annotation =
66+
ApiVersionAwareWidgetDependentResource.class.getAnnotation(KubernetesDependent.class);
67+
var config =
68+
converter.configFrom(
69+
annotation, spec(ApiVersionAwareWidgetDependentResource.class), controllerConfig());
70+
71+
assertThat(config.detectApiVersionChange()).isTrue();
72+
}
73+
74+
@SuppressWarnings({"unchecked", "rawtypes"})
75+
private static DependentResourceSpec<
76+
GenericKubernetesResource,
77+
ConfigMap,
78+
KubernetesDependentResourceConfig<GenericKubernetesResource>>
79+
spec(
80+
Class<? extends KubernetesDependentResource<GenericKubernetesResource, ConfigMap>>
81+
dependentResourceClass) {
82+
return new DependentResourceSpec(
83+
dependentResourceClass, "test", Set.of(), null, null, null, null, null);
84+
}
85+
86+
private static ControllerConfiguration<ConfigMap> controllerConfig() {
87+
ControllerConfiguration<ConfigMap> controllerConfig = mock();
88+
when(controllerConfig.getName()).thenReturn("test-reconciler");
89+
ConfigurationService configurationService = mock();
90+
when(configurationService.dependentResourceFactory())
91+
.thenReturn(DependentResourceFactory.DEFAULT);
92+
when(controllerConfig.getConfigurationService()).thenReturn(configurationService);
93+
return controllerConfig;
94+
}
95+
96+
@KubernetesDependent
97+
static class PlainWidgetDependentResource
98+
extends KubernetesDependentResource<GenericKubernetesResource, ConfigMap>
99+
implements GarbageCollected<ConfigMap> {
100+
public PlainWidgetDependentResource() {
101+
super(GenericKubernetesResource.class, null);
102+
}
103+
}
104+
105+
@KubernetesDependent(detectApiVersionChange = true)
106+
static class ApiVersionAwareWidgetDependentResource
107+
extends KubernetesDependentResource<GenericKubernetesResource, ConfigMap>
108+
implements GarbageCollected<ConfigMap> {
109+
public ApiVersionAwareWidgetDependentResource() {
110+
super(GenericKubernetesResource.class, null);
111+
}
112+
}
113+
}

0 commit comments

Comments
 (0)