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 2b3e317baa..8974c41f2a 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 @@ -289,6 +289,41 @@ If you encounter this issue on an older Kubernetes version, consider changing yo that resource, or even upgrading your Kubernetes version. If you encounter it on a newer Kubernetes version, please log an issue with the JOSDK and with upstream Kubernetes. +### Detecting dependent resource API version changes (experimental) + +When a dependent resource's CRD gains a new API version and the operator is upgraded to target it, +comparing `actualResource.getApiVersion()` with the desired resource's API version is not a +reliable way to detect resources that still need to be updated: the Kubernetes API server serves a +resource using the requested, served API version regardless of which version it is actually stored +as, so this comparison would always trivially match. + +`KubernetesDependentResource` therefore ignores `apiVersion` when matching. To still force a +one-time update of dependent resources after such an upgrade, without triggering an update on every +reconciliation, `KubernetesDependent` provides the opt-in, experimental +`detectApiVersionChange` flag: + +```java +@KubernetesDependent(detectApiVersionChange = true) +public class MyDependentResource extends CRUDKubernetesDependentResource { + // ... +} +``` + +When enabled, JOSDK records the API version it applies in the `javaoperatorsdk.io/last-applied-api-version` +annotation. On subsequent reconciliations, the resource is considered mismatched (and thus updated) +if that recorded marker differs from the API version the operator currently uses - this also +covers resources that predate this feature and therefore have no marker at all. Once the resource +has been updated, the marker matches the current API version again, so no further update is +requested until the API version changes again. + +This is disabled by default: existing behavior, including for resources created before this +feature existed, is unaffected unless you opt in. It does not read or infer the actual storage +version of the resource from the Kubernetes API, since that information is not reliably exposed; +it only tracks what the operator itself last applied. It is also not a replacement for +Kubernetes' [StorageVersionMigration](https://kubernetes.io/docs/tasks/manage-kubernetes-objects/storage-version-migration/), +which addresses migrating the stored representation of resources, a concern orthogonal to this +feature. + ## Telling JOSDK how to find which secondary resources are associated with a given primary resource [`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) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependent.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependent.java index 35bcde9052..a23d2b3aa8 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependent.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependent.java @@ -21,6 +21,9 @@ import java.lang.annotation.Target; import io.javaoperatorsdk.operator.api.config.informer.Informer; +import io.javaoperatorsdk.operator.api.reconciler.Experimental; + +import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE; @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.TYPE}) @@ -62,4 +65,32 @@ boolean createResourceOnlyIfNotExistingWithSSA() default */ Class matcher() default SSABasedGenericKubernetesResourceMatcher.class; + + /** + * Whether JOSDK should detect that the API version of this dependent resource's desired state has + * changed since it was last applied by the operator (for example after the operator was upgraded + * to target a new CRD version) and, in that case, request a one-time update of the actual + * resource. + * + *

When enabled, JOSDK records the API version it applies in the {@value + * KubernetesDependentResource#LAST_APPLIED_API_VERSION_ANNOTATION_KEY} annotation. On subsequent + * reconciliations, the resource is considered mismatched (and thus updated) if that recorded + * marker differs from the API version the operator currently uses, including when the marker is + * missing entirely (for example on resources created before this feature was enabled). Once the + * resource has been updated, the marker matches the current API version again, so no further + * update is requested until the API version changes again. + * + *

This is opt-in and disabled by default: when disabled, no marker annotation is ever added or + * read, and matching behavior is unchanged. It does not read or infer the actual storage version + * of the resource in Kubernetes, since that information is not reliably exposed by the API + * server; it only tracks what the operator itself last applied. It is not a replacement for + * Kubernetes' StorageVersionMigration. + * + * @return {@code true} if API version change detection is enabled, {@code false} otherwise + * @since 5.6 + */ + @Experimental(API_MIGHT_CHANGE) + boolean detectApiVersionChange() default + KubernetesDependentResourceConfig.DEFAULT_DETECT_API_VERSION_CHANGE; } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentConverter.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentConverter.java index d39066e5d9..00c802867c 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentConverter.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentConverter.java @@ -35,6 +35,8 @@ public KubernetesDependentResourceConfig configFrom( ControllerConfiguration controllerConfig) { var createResourceOnlyIfNotExistingWithSSA = DEFAULT_CREATE_RESOURCE_ONLY_IF_NOT_EXISTING_WITH_SSA; + var detectApiVersionChange = + KubernetesDependentResourceConfig.DEFAULT_DETECT_API_VERSION_CHANGE; Boolean useSSA = null; SSABasedGenericKubernetesResourceMatcher matcher = @@ -43,6 +45,7 @@ public KubernetesDependentResourceConfig configFrom( createResourceOnlyIfNotExistingWithSSA = configAnnotation.createResourceOnlyIfNotExistingWithSSA(); useSSA = configAnnotation.useSSA().asBoolean(); + detectApiVersionChange = configAnnotation.detectApiVersionChange(); // check if we have a specific matcher Class> dependentResourceClass = @@ -62,7 +65,11 @@ public KubernetesDependentResourceConfig configFrom( controllerConfig); return new KubernetesDependentResourceConfig<>( - useSSA, createResourceOnlyIfNotExistingWithSSA, informerConfiguration, matcher); + useSSA, + createResourceOnlyIfNotExistingWithSSA, + informerConfiguration, + matcher, + detectApiVersionChange); } @SuppressWarnings({"unchecked"}) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java index bb59d6eed6..654b71fc5c 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java @@ -15,6 +15,7 @@ */ package io.javaoperatorsdk.operator.processing.dependent.kubernetes; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -52,6 +53,15 @@ public abstract class KubernetesDependentResource kubernetesDependentResourceConfig; private volatile Boolean useSSA; @@ -160,6 +170,12 @@ public Result match(R actualResource, R desired, P primary, Context

contex protected void addMetadata( boolean forMatch, R actualResource, final R target, P primary, Context

context) { + if (kubernetesDependentResourceConfig != null + && kubernetesDependentResourceConfig.detectApiVersionChange()) { + // desired resources might expose a null or immutable annotations map (e.g. Map.of(...)); + // make sure it's a mutable one before this method or its callees write to it + ensureMutableAnnotations(target); + } if (forMatch) { // keep the current previous annotation String actual = actualResource @@ -173,9 +189,36 @@ protected void addMetadata( annotations.remove(InformerEventSource.PREVIOUS_ANNOTATION_KEY); } } + addLastAppliedApiVersion(target); addReferenceHandlingMetadata(target, primary); } + private static void ensureMutableAnnotations(HasMetadata target) { + var metadata = target.getMetadata(); + metadata.setAnnotations( + new LinkedHashMap<>(Optional.ofNullable(metadata.getAnnotations()).orElseGet(Map::of))); + } + + /** + * When {@link KubernetesDependentResourceConfig#detectApiVersionChange()} is enabled, marks the + * target resource with the API version the operator is currently applying. Comparing this marker + * with the one recorded on the actual resource lets the regular matching logic detect a mismatch, + * without ever inspecting the actual, potentially unreliable, stored API version. + */ + private void addLastAppliedApiVersion(R target) { + if (kubernetesDependentResourceConfig == null + || !kubernetesDependentResourceConfig.detectApiVersionChange()) { + return; + } + var apiVersion = target.getApiVersion(); + if (apiVersion != null) { + target + .getMetadata() + .getAnnotations() + .put(LAST_APPLIED_API_VERSION_ANNOTATION_KEY, apiVersion); + } + } + protected boolean useSSA(Context

context) { if (useSSA == null) { useSSA = diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfig.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfig.java index 05ff71335c..b7f8db5439 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfig.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfig.java @@ -21,11 +21,13 @@ public class KubernetesDependentResourceConfig { public static final boolean DEFAULT_CREATE_RESOURCE_ONLY_IF_NOT_EXISTING_WITH_SSA = true; + public static final boolean DEFAULT_DETECT_API_VERSION_CHANGE = false; private final Boolean useSSA; private final boolean createResourceOnlyIfNotExistingWithSSA; private final InformerConfiguration informerConfig; private final SSABasedGenericKubernetesResourceMatcher matcher; + private final boolean detectApiVersionChange; public KubernetesDependentResourceConfig( Boolean useSSA, @@ -39,11 +41,26 @@ public KubernetesDependentResourceConfig( boolean createResourceOnlyIfNotExistingWithSSA, InformerConfiguration informerConfig, SSABasedGenericKubernetesResourceMatcher matcher) { + this( + useSSA, + createResourceOnlyIfNotExistingWithSSA, + informerConfig, + matcher, + DEFAULT_DETECT_API_VERSION_CHANGE); + } + + public KubernetesDependentResourceConfig( + Boolean useSSA, + boolean createResourceOnlyIfNotExistingWithSSA, + InformerConfiguration informerConfig, + SSABasedGenericKubernetesResourceMatcher matcher, + boolean detectApiVersionChange) { this.useSSA = useSSA; this.createResourceOnlyIfNotExistingWithSSA = createResourceOnlyIfNotExistingWithSSA; this.informerConfig = informerConfig; this.matcher = matcher != null ? matcher : SSABasedGenericKubernetesResourceMatcher.getInstance(); + this.detectApiVersionChange = detectApiVersionChange; } public boolean createResourceOnlyIfNotExistingWithSSA() { @@ -61,4 +78,16 @@ public InformerConfiguration informerConfig() { public SSABasedGenericKubernetesResourceMatcher matcher() { return matcher; } + + /** + * Whether JOSDK should detect when the API version of this dependent resource's desired state has + * changed since it was last applied by the operator and, in that case, request a one-time update + * of the actual resource. + * + * @return {@code true} if API version change detection is enabled, {@code false} otherwise + * @since 5.6 + */ + public boolean detectApiVersionChange() { + return detectApiVersionChange; + } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfigBuilder.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfigBuilder.java index bdd6b068b3..3463eea7f1 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfigBuilder.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceConfigBuilder.java @@ -24,6 +24,8 @@ public final class KubernetesDependentResourceConfigBuilder informerConfiguration; private SSABasedGenericKubernetesResourceMatcher matcher; + private boolean detectApiVersionChange = + KubernetesDependentResourceConfig.DEFAULT_DETECT_API_VERSION_CHANGE; public KubernetesDependentResourceConfigBuilder() {} @@ -51,8 +53,18 @@ public KubernetesDependentResourceConfigBuilder withSSAMatcher( return this; } + public KubernetesDependentResourceConfigBuilder withDetectApiVersionChange( + boolean detectApiVersionChange) { + this.detectApiVersionChange = detectApiVersionChange; + return this; + } + public KubernetesDependentResourceConfig build() { return new KubernetesDependentResourceConfig<>( - useSSA, createResourceOnlyIfNotExistingWithSSA, informerConfiguration, matcher); + useSSA, + createResourceOnlyIfNotExistingWithSSA, + informerConfiguration, + matcher, + detectApiVersionChange); } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentConverterTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentConverterTest.java new file mode 100644 index 0000000000..2cc2a37721 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentConverterTest.java @@ -0,0 +1,113 @@ +/* + * 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.processing.dependent.kubernetes; + +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory; +import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Focused unit test for the {@code detectApiVersionChange} wiring performed by {@link + * KubernetesDependentConverter}, independent of the shared, process-wide {@link + * io.javaoperatorsdk.operator.api.config.dependent.DependentResourceConfigurationResolver} state + * that other tests in this module mutate. + */ +class KubernetesDependentConverterTest { + + private final KubernetesDependentConverter converter = + new KubernetesDependentConverter<>(); + + @Test + void detectApiVersionChangeDefaultsToFalseWhenAnnotationAbsent() { + var config = + converter.configFrom(null, spec(PlainWidgetDependentResource.class), controllerConfig()); + + assertThat(config.detectApiVersionChange()).isFalse(); + } + + @Test + void detectApiVersionChangeDefaultsToFalseWhenNotSetOnAnnotation() { + var annotation = PlainWidgetDependentResource.class.getAnnotation(KubernetesDependent.class); + var config = + converter.configFrom( + annotation, spec(PlainWidgetDependentResource.class), controllerConfig()); + + assertThat(config.detectApiVersionChange()).isFalse(); + } + + @Test + void detectApiVersionChangeCanBeEnabledViaAnnotation() { + var annotation = + ApiVersionAwareWidgetDependentResource.class.getAnnotation(KubernetesDependent.class); + var config = + converter.configFrom( + annotation, spec(ApiVersionAwareWidgetDependentResource.class), controllerConfig()); + + assertThat(config.detectApiVersionChange()).isTrue(); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static DependentResourceSpec< + GenericKubernetesResource, + ConfigMap, + KubernetesDependentResourceConfig> + spec( + Class> + dependentResourceClass) { + return new DependentResourceSpec( + dependentResourceClass, "test", Set.of(), null, null, null, null, null); + } + + private static ControllerConfiguration controllerConfig() { + ControllerConfiguration controllerConfig = mock(); + when(controllerConfig.getName()).thenReturn("test-reconciler"); + ConfigurationService configurationService = mock(); + when(configurationService.dependentResourceFactory()) + .thenReturn(DependentResourceFactory.DEFAULT); + when(controllerConfig.getConfigurationService()).thenReturn(configurationService); + return controllerConfig; + } + + @KubernetesDependent + static class PlainWidgetDependentResource + extends KubernetesDependentResource + implements GarbageCollected { + public PlainWidgetDependentResource() { + super(GenericKubernetesResource.class, null); + } + } + + @KubernetesDependent(detectApiVersionChange = true) + static class ApiVersionAwareWidgetDependentResource + extends KubernetesDependentResource + implements GarbageCollected { + public ApiVersionAwareWidgetDependentResource() { + super(GenericKubernetesResource.class, null); + } + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceApiVersionChangeTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceApiVersionChangeTest.java new file mode 100644 index 0000000000..8c7bb0502a --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResourceApiVersionChangeTest.java @@ -0,0 +1,343 @@ +/* + * 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.processing.dependent.kubernetes; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.FieldsV1; +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.ManagedFieldsEntry; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.MockKubernetesClient; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Verifies the opt-in behavior enabled by {@link + * KubernetesDependentResourceConfig#detectApiVersionChange()}: a dependent resource is considered + * mismatched when the API version marker last applied by the operator differs from the one it would + * currently apply, without causing updates on every reconciliation once the marker is up-to-date. + */ +class KubernetesDependentResourceApiVersionChangeTest { + + private static final String FIELD_MANAGER = "controller"; + private static final String OLD_API_VERSION = "example.com/v1alpha1"; + private static final String NEW_API_VERSION = "example.com/v1"; + + @Test + void featureDisabledByDefaultDoesNotAddMarker() { + var dr = newDependentResource(false); + var context = context(false); + + var actual = widget(NEW_API_VERSION, null, 3); + var desired = widget(NEW_API_VERSION, null, 3); + + var result = dr.match(actual, desired, primary(), context); + + assertThat(result.matched()).isTrue(); + assertThat(desired.getMetadata().getAnnotations()) + .doesNotContainKey(KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY); + } + + @Test + void featureDisabledIgnoresPreExistingMarkerMismatch() { + var dr = newDependentResource(false); + var context = context(false); + + var actual = + widget( + NEW_API_VERSION, + Map.of( + KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY, + OLD_API_VERSION), + 3); + var desired = widget(NEW_API_VERSION, null, 3); + + var result = dr.match(actual, desired, primary(), context); + + assertThat(result.matched()) + .withFailMessage("Disabled feature must reproduce current, unaffected behavior") + .isTrue(); + } + + @Test + void nonSSA_missingMarkerCausesMismatchAndMarksDesired() { + var dr = newDependentResource(true); + var context = context(false); + + var actual = widget(NEW_API_VERSION, null, 3); + var desired = widget(NEW_API_VERSION, null, 3); + + var result = dr.match(actual, desired, primary(), context); + + assertThat(result.matched()) + .withFailMessage("A resource with no marker annotation must be considered mismatched") + .isFalse(); + assertThat(desired.getMetadata().getAnnotations()) + .containsEntry( + KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY, NEW_API_VERSION); + } + + @Test + void nonSSA_matchingMarkerMatches() { + var dr = newDependentResource(true); + var context = context(false); + + var actual = + widget( + NEW_API_VERSION, + Map.of( + KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY, + NEW_API_VERSION), + 3); + var desired = widget(NEW_API_VERSION, null, 3); + + var result = dr.match(actual, desired, primary(), context); + + assertThat(result.matched()).isTrue(); + } + + @Test + void nonSSA_staleMarkerCausesMismatch() { + var dr = newDependentResource(true); + var context = context(false); + + var actual = + widget( + NEW_API_VERSION, + Map.of( + KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY, + OLD_API_VERSION), + 3); + var desired = widget(NEW_API_VERSION, null, 3); + + var result = dr.match(actual, desired, primary(), context); + + assertThat(result.matched()) + .withFailMessage("A stale marker must cause an update to be requested") + .isFalse(); + assertThat(desired.getMetadata().getAnnotations()) + .withFailMessage("The desired resource must be marked with the new API version") + .containsEntry( + KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY, NEW_API_VERSION); + } + + @Test + void nonSSA_matchingMarkerStillDetectsUnrelatedSpecChanges() { + var dr = newDependentResource(true); + var context = context(false); + + var actual = + widget( + NEW_API_VERSION, + Map.of( + KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY, + NEW_API_VERSION), + 3); + var desired = widget(NEW_API_VERSION, null, 4); + + var result = dr.match(actual, desired, primary(), context); + + assertThat(result.matched()) + .withFailMessage("Normal spec matching must remain intact regardless of the marker") + .isFalse(); + } + + @Test + void nonSSA_missingApiVersionOnDesiredIsHandledSafely() { + var dr = newDependentResource(true); + var context = context(false); + + var actual = widget(NEW_API_VERSION, null, 3); + var desired = widget(null, null, 3); + + var result = dr.match(actual, desired, primary(), context); + + assertThat(result.matched()) + .withFailMessage("A null desired API version must not prevent normal matching") + .isTrue(); + assertThat(desired.getMetadata().getAnnotations()) + .doesNotContainKey(KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY); + } + + @Test + void nonSSA_preservesExistingAnnotationsWhenMarkingEvenIfImmutable() { + var dr = newDependentResource(true); + var context = context(false); + + var actual = widget(NEW_API_VERSION, null, 3); + var desired = widget(NEW_API_VERSION, null, 3); + // simulate a desired resource whose annotations map is immutable, as returned by Map.of(...) + desired.getMetadata().setAnnotations(Map.of("user.example.com/owner", "team-a")); + + var result = dr.match(actual, desired, primary(), context); + + assertThat(result.matched()) + .withFailMessage("A missing marker must still cause a mismatch") + .isFalse(); + assertThat(desired.getMetadata().getAnnotations()) + .withFailMessage("Existing annotations must be preserved alongside the new marker") + .containsEntry("user.example.com/owner", "team-a") + .containsEntry( + KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY, NEW_API_VERSION); + } + + @Test + void ssa_missingMarkerCausesMismatchAndMarksDesired() { + var dr = newDependentResource(true); + var context = context(true); + + var actual = widget(NEW_API_VERSION, null, 3); + actual.getMetadata().setManagedFields(List.of(managedFieldsEntry(false))); + var desired = widget(NEW_API_VERSION, null, 3); + + var result = dr.match(actual, desired, primary(), context); + + assertThat(result.matched()) + .withFailMessage("A resource applied before the marker existed must be updated once") + .isFalse(); + assertThat(desired.getMetadata().getAnnotations()) + .containsEntry( + KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY, NEW_API_VERSION); + } + + @Test + void ssa_matchingMarkerMatches() { + var dr = newDependentResource(true); + var context = context(true); + + var actual = + widget( + NEW_API_VERSION, + Map.of( + KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY, + NEW_API_VERSION), + 3); + actual.getMetadata().setManagedFields(List.of(managedFieldsEntry(true))); + var desired = widget(NEW_API_VERSION, null, 3); + + var result = dr.match(actual, desired, primary(), context); + + assertThat(result.matched()) + .withFailMessage("No further update should be requested once the marker is up-to-date") + .isTrue(); + } + + @Test + void ssa_staleMarkerCausesMismatch() { + var dr = newDependentResource(true); + var context = context(true); + + var actual = + widget( + NEW_API_VERSION, + Map.of( + KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY, + OLD_API_VERSION), + 3); + actual.getMetadata().setManagedFields(List.of(managedFieldsEntry(true))); + var desired = widget(NEW_API_VERSION, null, 3); + + var result = dr.match(actual, desired, primary(), context); + + assertThat(result.matched()) + .withFailMessage("A stale marker recorded via SSA must still cause a mismatch") + .isFalse(); + } + + private static WidgetDependentResourceForTest newDependentResource( + boolean detectApiVersionChange) { + var dr = new WidgetDependentResourceForTest(); + dr.configureWith( + new KubernetesDependentResourceConfigBuilder() + .withDetectApiVersionChange(detectApiVersionChange) + .build()); + return dr; + } + + private static HasMetadata primary() { + return mock(); + } + + @SuppressWarnings("unchecked") + private static Context context(boolean useSSA) { + Context context = mock(); + var client = MockKubernetesClient.client(HasMetadata.class); + when(context.getClient()).thenReturn(client); + + var configurationService = mock(ConfigurationService.class); + when(configurationService.shouldUseSSA(any(), any(), any())).thenReturn(useSSA); + ControllerConfiguration controllerConfiguration = mock(); + when(controllerConfiguration.getConfigurationService()).thenReturn(configurationService); + when(controllerConfiguration.fieldManager()).thenReturn(FIELD_MANAGER); + when(context.getControllerConfiguration()).thenReturn(controllerConfiguration); + return context; + } + + private static GenericKubernetesResource widget( + String apiVersion, Map annotations, int specSize) { + var resource = new GenericKubernetesResource(); + resource.setApiVersion(apiVersion); + resource.setKind("Widget"); + var metadataBuilder = new ObjectMetaBuilder().withName("test").withNamespace("default"); + if (annotations != null) { + metadataBuilder.withAnnotations(annotations); + } + resource.setMetadata(metadataBuilder.build()); + resource.setAdditionalProperty("spec", Map.of("size", specSize)); + return resource; + } + + private static ManagedFieldsEntry managedFieldsEntry(boolean managesAnnotation) { + Map fields = new LinkedHashMap<>(); + fields.put("f:spec", Map.of("f:size", Map.of())); + if (managesAnnotation) { + fields.put( + "f:metadata", + Map.of( + "f:annotations", + Map.of( + "f:" + KubernetesDependentResource.LAST_APPLIED_API_VERSION_ANNOTATION_KEY, + Map.of()))); + } + var fieldsV1 = new FieldsV1(); + fieldsV1.setAdditionalProperties(fields); + + var entry = new ManagedFieldsEntry(); + entry.setManager(FIELD_MANAGER); + entry.setOperation("Apply"); + entry.setFieldsV1(fieldsV1); + return entry; + } + + private static class WidgetDependentResourceForTest + extends KubernetesDependentResource { + public WidgetDependentResourceForTest() { + super(GenericKubernetesResource.class, null); + } + } +}