diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/DesiredEqualsMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/DesiredEqualsMatcher.java deleted file mode 100644 index 459d7951d6..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/DesiredEqualsMatcher.java +++ /dev/null @@ -1,19 +0,0 @@ -package io.javaoperatorsdk.operator.processing.dependent; - -import io.fabric8.kubernetes.api.model.HasMetadata; -import io.javaoperatorsdk.operator.api.reconciler.Context; - -public class DesiredEqualsMatcher implements Matcher { - - private final AbstractDependentResource abstractDependentResource; - - public DesiredEqualsMatcher(AbstractDependentResource abstractDependentResource) { - this.abstractDependentResource = abstractDependentResource; - } - - @Override - public Result match(R actualResource, P primary, Context

context) { - var desired = abstractDependentResource.desired(primary, context); - return Result.computed(actualResource.equals(desired), desired); - } -} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/Matcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/Matcher.java index 750fe89cbf..f8bababb42 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/Matcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/Matcher.java @@ -92,7 +92,7 @@ public Optional computedDesired() { * @return a {@link Result} encapsulating whether the resource matched its desired state and this * associated state if it was computed as part of the matching process. Use the static * convenience methods ({@link Result#nonComputed(boolean)} and - * {@link Result#computed(boolean, Object)}) + * {@link Result#computed(boolean, Object)}) to create your return {@link Result}. */ Result match(R actualResource, P primary, Context

context); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcher.java index 6a16d21b44..3c2d3e8401 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/GenericKubernetesResourceMatcher.java @@ -1,40 +1,63 @@ package io.javaoperatorsdk.operator.processing.dependent.kubernetes; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.Objects; +import java.util.Optional; import io.fabric8.kubernetes.api.model.ConfigMap; import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.api.model.Secret; import io.fabric8.zjsonpatch.JsonDiff; -import io.javaoperatorsdk.operator.ReconcilerUtils; import io.javaoperatorsdk.operator.api.config.ConfigurationServiceProvider; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.processing.dependent.Matcher; +import com.fasterxml.jackson.databind.JsonNode; + public class GenericKubernetesResourceMatcher implements Matcher { + private static final String SPEC = "/spec"; + private static final String METADATA_LABELS = "/metadata/labels"; + private static final String METADATA_ANNOTATIONS = "/metadata/annotations"; + private static final String ADD = "add"; + private static final String OP = "op"; + private static final String PATH = "path"; + private final static String[] EMPTY_ARRAY = {}; private final KubernetesDependentResource dependentResource; private GenericKubernetesResourceMatcher(KubernetesDependentResource dependentResource) { this.dependentResource = dependentResource; } - @SuppressWarnings({"unchecked", "rawtypes"}) + @SuppressWarnings({"unchecked", "rawtypes", "unused"}) static Matcher matcherFor( Class resourceType, KubernetesDependentResource dependentResource) { return new GenericKubernetesResourceMatcher(dependentResource); } + /** + * {@inheritDoc} + *

+ * This implementation attempts to cover most common cases out of the box by considering + * non-additive changes to the resource's spec (if the resource in question has a {@code spec} + * field), making special provisions for {@link ConfigMap} and {@link Secret} resources. Additive + * changes (i.e. a field is added that previously didn't exist) are not considered as triggering a + * mismatch by default to account for validating webhooks that might add default values + * automatically when not present or some other controller adding labels and/or annotations. + *

+ * It should be noted that this implementation is potentially intensive because it generically + * attempts to cover common use cases by performing diffs on the JSON representation of objects. + * If performance is a concern, it might be easier / simpler to provide a {@link Matcher} + * implementation optimized for your use case. + */ @Override public Result match(R actualResource, P primary, Context

context) { var desired = dependentResource.desired(primary, context); - return match(desired, actualResource, false, false); - } - - public static Result match(R desired, R actualResource, - boolean considerMetadata) { - return match(desired, actualResource, considerMetadata, false); + return match(desired, actualResource, false, false, false); } /** @@ -43,10 +66,14 @@ public static Result match(R desired, R actualResourc * * @param desired the desired resource * @param actualResource the actual resource - * @param considerMetadata {@code true} if labels and annotations will be checked for equality, - * {@code false} otherwise (meaning that metadata changes will be ignored for matching - * purposes) - * @param equality if {@code false}, the algorithm checks if the properties in the desired + * @param considerLabelsAndAnnotations {@code true} if labels and annotations will be checked for + * equality, {@code false} otherwise (meaning that metadata changes will be ignored for + * matching purposes) + * @param labelsAndAnnotationsEquality if true labels and annotation match exactly in the actual + * and desired state if false, additional elements are allowed in actual annotations. + * Considered only if considerLabelsAndAnnotations is true. + * + * @param specEquality if {@code false}, the algorithm checks if the properties in the desired * resource spec are same as in the actual resource spec. The reason is that admission * controllers and default Kubernetes controllers might add default values to some * properties which are not set in the desired resources' spec and comparing it with simple @@ -62,18 +89,112 @@ public static Result match(R desired, R actualResourc * @param resource */ public static Result match(R desired, R actualResource, - boolean considerMetadata, boolean equality) { + boolean considerLabelsAndAnnotations, boolean labelsAndAnnotationsEquality, + boolean specEquality) { + return match(desired, actualResource, considerLabelsAndAnnotations, + labelsAndAnnotationsEquality, specEquality, EMPTY_ARRAY); + } + + /** + * Determines whether the specified actual resource matches the specified desired resource, + * possibly considering metadata and deeper equality checks. + * + * @param desired the desired resource + * @param actualResource the actual resource + * @param considerLabelsAndAnnotations {@code true} if labels and annotations will be checked for + * equality, {@code false} otherwise (meaning that metadata changes will be ignored for + * matching purposes) + * @param labelsAndAnnotationsEquality if true labels and annotation match exactly in the actual + * and desired state if false, additional elements are allowed in actual annotations. + * Considered only if considerLabelsAndAnnotations is true. + * + * @param ignorePaths are paths in the resource that are ignored on matching (basically an ignore + * list). All changes with a target prefix path on a calculated JSON Patch between actual + * and desired will be ignored. If there are other changes, non-present on ignore list + * match fails. + * @return results of matching + * @param resource + */ + public static Result match(R desired, R actualResource, + boolean considerLabelsAndAnnotations, boolean labelsAndAnnotationsEquality, + String... ignorePaths) { + return match(desired, actualResource, considerLabelsAndAnnotations, + labelsAndAnnotationsEquality, false, ignorePaths); + } + + /** + * Determines whether the specified actual resource matches the desired state defined by the + * specified {@link KubernetesDependentResource} based on the observed state of the associated + * specified primary resource. + * + * @param dependentResource the {@link KubernetesDependentResource} implementation used to compute + * the desired state associated with the specified primary resource + * @param actualResource the observed dependent resource for which we want to determine whether it + * matches the desired state or not + * @param primary the primary resource from which we want to compute the desired state + * @param context the {@link Context} instance within which this method is called + * @param considerLabelsAndAnnotations {@code true} to consider the metadata of the actual + * resource when determining if it matches the desired state, {@code false} if matching + * should occur only considering the spec of the resources + * @param labelsAndAnnotationsEquality if true labels and annotation match exactly in the actual + * and desired state if false, additional elements are allowed in actual annotations. + * Considered only if considerLabelsAndAnnotations is true. + * @param the type of resource we want to determine whether they match or not + * @param

the type of primary resources associated with the secondary resources we want to + * match + * @param ignorePaths are paths in the resource that are ignored on matching (basically an ignore + * list). All changes with a target prefix path on a calculated JSON Patch between actual + * and desired will be ignored. If there are other changes, non-present on ignore list + * match fails. + * @return a {@link io.javaoperatorsdk.operator.processing.dependent.Matcher.Result} object + */ + public static Result match( + KubernetesDependentResource dependentResource, R actualResource, P primary, + Context

context, boolean considerLabelsAndAnnotations, + boolean labelsAndAnnotationsEquality, + String... ignorePaths) { + final var desired = dependentResource.desired(primary, context); + return match(desired, actualResource, considerLabelsAndAnnotations, + labelsAndAnnotationsEquality, ignorePaths); + } + + public static Result match( + KubernetesDependentResource dependentResource, R actualResource, P primary, + Context

context, boolean considerLabelsAndAnnotations, + boolean labelsAndAnnotationsEquality, + boolean specEquality) { + final var desired = dependentResource.desired(primary, context); + return match(desired, actualResource, considerLabelsAndAnnotations, + labelsAndAnnotationsEquality, specEquality); + } + + private static Result match(R desired, R actualResource, + boolean considerMetadata, boolean labelsAndAnnotationsEquality, boolean specEquality, + String... ignoredPaths) { + final List ignoreList = + ignoredPaths != null && ignoredPaths.length > 0 ? Arrays.asList(ignoredPaths) + : Collections.emptyList(); + + if (specEquality && !ignoreList.isEmpty()) { + throw new IllegalArgumentException( + "Equality should be false in case of ignore list provided"); + } + + final var objectMapper = ConfigurationServiceProvider.instance().getObjectMapper(); + + var desiredNode = objectMapper.valueToTree(desired); + var actualNode = objectMapper.valueToTree(actualResource); + var wholeDiffJsonPatch = JsonDiff.asJson(desiredNode, actualNode); + + var considerIgnoreList = !specEquality && !ignoreList.isEmpty(); + if (considerMetadata) { - final var desiredMetadata = desired.getMetadata(); - final var actualMetadata = actualResource.getMetadata(); - final var matched = - Objects.equals(desiredMetadata.getAnnotations(), actualMetadata.getAnnotations()) && - Objects.equals(desiredMetadata.getLabels(), actualMetadata.getLabels()); - if (!matched) { - return Result.computed(false, desired); + Optional> res = + matchMetadata(desired, actualResource, labelsAndAnnotationsEquality, wholeDiffJsonPatch); + if (res.isPresent()) { + return res.orElseThrow(); } } - if (desired instanceof ConfigMap) { return Result.computed( ResourceComparators.compareConfigMapData((ConfigMap) desired, (ConfigMap) actualResource), @@ -83,60 +204,110 @@ public static Result match(R desired, R actualResourc ResourceComparators.compareSecretData((Secret) desired, (Secret) actualResource), desired); } else { - final var objectMapper = ConfigurationServiceProvider.instance().getObjectMapper(); - - // reflection will be replaced by this: - // https://github.com/fabric8io/kubernetes-client/issues/3816 - var desiredSpecNode = objectMapper.valueToTree(ReconcilerUtils.getSpec(desired)); - var actualSpecNode = objectMapper.valueToTree(ReconcilerUtils.getSpec(actualResource)); - var diffJsonPatch = JsonDiff.asJson(desiredSpecNode, actualSpecNode); - // In case of equality is set to true, no diffs are allowed, so we return early if diffs exist - // On contrary (if equality is false), "add" is allowed for cases when for some - // resources Kubernetes fills-in values into spec. - if (equality && diffJsonPatch.size() > 0) { + return matchSpec(desired, specEquality, ignoreList, wholeDiffJsonPatch, considerIgnoreList); + } + } + + private static Result matchSpec(R desired, boolean specEquality, + List ignoreList, JsonNode wholeDiffJsonPatch, boolean considerIgnoreList) { + // reflection will be replaced by this: + // https://github.com/fabric8io/kubernetes-client/issues/3816 + var specDiffJsonPatch = getDiffsImpactingPathsWithPrefixes(wholeDiffJsonPatch, SPEC); + // In case of equality is set to true, no diffs are allowed, so we return early if diffs exist + // On contrary (if equality is false), "add" is allowed for cases when for some + // resources Kubernetes fills-in values into spec. + if (specEquality && !specDiffJsonPatch.isEmpty()) { + return Result.computed(false, desired); + } + if (considerIgnoreList) { + if (!allDiffsOnIgnoreList(specDiffJsonPatch, ignoreList)) { + return Result.computed(false, desired); + } + } else { + if (!allDiffsAreAddOps(specDiffJsonPatch)) { return Result.computed(false, desired); } + } + return Result.computed(true, desired); + } + + private static Optional> matchMetadata(R desired, + R actualResource, boolean labelsAndAnnotationsEquality, JsonNode wholeDiffJsonPatch) { + if (labelsAndAnnotationsEquality) { + final var desiredMetadata = desired.getMetadata(); + final var actualMetadata = actualResource.getMetadata(); + + final var matched = + Objects.equals(desiredMetadata.getAnnotations(), actualMetadata.getAnnotations()) && + Objects.equals(desiredMetadata.getLabels(), actualMetadata.getLabels()); + if (!matched) { + return Optional.of(Result.computed(false, desired)); + } + } else { + var metadataJSonDiffs = getDiffsImpactingPathsWithPrefixes(wholeDiffJsonPatch, + METADATA_LABELS, + METADATA_ANNOTATIONS); + if (!allDiffsAreAddOps(metadataJSonDiffs)) { + return Optional.of(Result.computed(false, desired)); + } + } + return Optional.empty(); + } + + private static boolean allDiffsAreAddOps(List metadataJSonDiffs) { + if (metadataJSonDiffs.isEmpty()) { + return true; + } + return metadataJSonDiffs.stream().allMatch(n -> ADD.equals(n.get(OP).asText())); + } + + private static boolean allDiffsOnIgnoreList(List metadataJSonDiffs, + List ignoreList) { + if (metadataJSonDiffs.isEmpty()) { + return false; + } + return metadataJSonDiffs.stream().allMatch(n -> nodeIsChildOf(n, ignoreList)); + } + + private static boolean nodeIsChildOf(JsonNode n, List prefixes) { + var path = getPath(n); + return prefixes.stream().anyMatch(path::startsWith); + } + + private static String getPath(JsonNode n) { + return n.get(PATH).asText(); + } + + private static List getDiffsImpactingPathsWithPrefixes(JsonNode diffJsonPatch, + String... prefixes) { + if (prefixes != null && prefixes.length > 0) { + var res = new ArrayList(); + var prefixList = Arrays.asList(prefixes); for (int i = 0; i < diffJsonPatch.size(); i++) { - String operation = diffJsonPatch.get(i).get("op").asText(); - if (!operation.equals("add")) { - return Result.computed(false, desired); + var node = diffJsonPatch.get(i); + if (nodeIsChildOf(node, prefixList)) { + res.add(node); } } - return Result.computed(true, desired); + return res; } + return Collections.emptyList(); } - /** - * Determines whether the specified actual resource matches the desired state defined by the - * specified {@link KubernetesDependentResource} based on the observed state of the associated - * specified primary resource. - * - * @param dependentResource the {@link KubernetesDependentResource} implementation used to - * computed the desired state associated with the specified primary resource - * @param actualResource the observed dependent resource for which we want to determine whether it - * matches the desired state or not - * @param primary the primary resource from which we want to compute the desired state - * @param context the {@link Context} instance within which this method is called - * @param considerMetadata {@code true} to consider the metadata of the actual resource when - * determining if it matches the desired state, {@code false} if matching should occur only - * considering the spec of the resources - * @return a {@link io.javaoperatorsdk.operator.processing.dependent.Matcher.Result} object - * @param the type of resource we want to determine whether they match or not - * @param

the type of primary resources associated with the secondary resources we want to - * match - * @param strongEquality if the resource should match exactly - */ + @Deprecated(forRemoval = true) public static Result match( KubernetesDependentResource dependentResource, R actualResource, P primary, - Context

context, boolean considerMetadata, boolean strongEquality) { + Context

context, boolean considerLabelsAndAnnotations, boolean specEquality) { final var desired = dependentResource.desired(primary, context); - return match(desired, actualResource, considerMetadata, strongEquality); + return match(desired, actualResource, considerLabelsAndAnnotations, specEquality); } + @Deprecated(forRemoval = true) public static Result match( KubernetesDependentResource dependentResource, R actualResource, P primary, - Context

context, boolean considerMetadata) { + Context

context, boolean considerLabelsAndAnnotations, String... ignorePaths) { final var desired = dependentResource.desired(primary, context); - return match(desired, actualResource, considerMetadata, false); + return match(desired, actualResource, considerLabelsAndAnnotations, true, ignorePaths); } + } 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 5e2b209a71..a6676fd3f1 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 @@ -145,7 +145,8 @@ public Result match(R actualResource, P primary, Context

context) { @SuppressWarnings("unused") public Result match(R actualResource, R desired, P primary, Context

context) { - return GenericKubernetesResourceMatcher.match(desired, actualResource, false); + return GenericKubernetesResourceMatcher.match(desired, actualResource, false, + false, false); } protected void handleDelete(P primary, R secondary, Context

context) { 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 91a71067ca..a23af75d9c 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 @@ -11,6 +11,7 @@ import io.javaoperatorsdk.operator.ReconcilerUtils; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.Matcher; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -21,6 +22,12 @@ class GenericKubernetesResourceMatcherTest { private static final Context context = mock(Context.class); + Deployment actual = createDeployment(); + Deployment desired = createDeployment(); + TestDependentResource dependentResource = new TestDependentResource(desired); + Matcher matcher = + GenericKubernetesResourceMatcher.matcherFor(Deployment.class, dependentResource); + @BeforeAll static void setUp() { final var controllerConfiguration = mock(ControllerConfiguration.class); @@ -28,37 +35,81 @@ static void setUp() { } @Test - void checksIfDesiredValuesAreTheSame() { - var actual = createDeployment(); - final var desired = createDeployment(); - final var dependentResource = new TestDependentResource(desired); - final var matcher = - GenericKubernetesResourceMatcher.matcherFor(Deployment.class, dependentResource); + void matchesTrivialCases() { assertThat(matcher.match(actual, null, context).matched()).isTrue(); - assertThat(matcher.match(actual, null, context).computedDesired().isPresent()).isTrue(); - assertThat(matcher.match(actual, null, context).computedDesired().get()).isEqualTo(desired); + assertThat(matcher.match(actual, null, context).computedDesired()).isPresent(); + assertThat(matcher.match(actual, null, context).computedDesired()).contains(desired); + } + @Test + void matchesAdditiveOnlyChanges() { actual.getSpec().getTemplate().getMetadata().getLabels().put("new-key", "val"); assertThat(matcher.match(actual, null, context).matched()) - .withFailMessage("Additive changes should be ok") + .withFailMessage("Additive changes should not cause a mismatch by default") .isTrue(); + } + @Test + void matchesWithStrongSpecEquality() { + actual.getSpec().getTemplate().getMetadata().getLabels().put("new-key", "val"); assertThat(GenericKubernetesResourceMatcher - .match(dependentResource, actual, null, context, true, true).matched()) - .withFailMessage("Strong equality does not ignore additive changes on spec") + .match(dependentResource, actual, null, context, true, true, + true) + .matched()) + .withFailMessage("Adding values should fail matching when strong equality is required") .isFalse(); + } + @Test + void doesNotMatchRemovedValues() { actual = createDeployment(); assertThat(matcher.match(actual, createPrimary("removed"), context).matched()) - .withFailMessage("Removed value should not be ok") + .withFailMessage("Removing values in metadata should lead to a mismatch") .isFalse(); + } + @Test + void doesNotMatchChangedValues() { actual = createDeployment(); actual.getSpec().setReplicas(2); assertThat(matcher.match(actual, null, context).matched()) - .withFailMessage("Changed values are not ok") + .withFailMessage("Should not have matched because values have changed") .isFalse(); + } + + @Test + void doesNotMatchChangedValuesWhenNoIgnoredPathsAreProvided() { + actual = createDeployment(); + actual.getSpec().setReplicas(2); + assertThat(GenericKubernetesResourceMatcher + .match(dependentResource, actual, null, context, true).matched()) + .withFailMessage( + "Should not have matched because values have changed and no ignored path is provided") + .isFalse(); + } + @Test + void doesNotAttemptToMatchIgnoredPaths() { + actual = createDeployment(); + actual.getSpec().setReplicas(2); + assertThat(GenericKubernetesResourceMatcher + .match(dependentResource, actual, null, context, false, "/spec/replicas").matched()) + .withFailMessage("Should not have compared ignored paths") + .isTrue(); + } + + @Test + void ignoresWholeSubPath() { + actual = createDeployment(); + actual.getSpec().getTemplate().getMetadata().getLabels().put("additional-key", "val"); + assertThat(GenericKubernetesResourceMatcher + .match(dependentResource, actual, null, context, false, "/spec/template").matched()) + .withFailMessage("Should match when only changes impact ignored sub-paths") + .isTrue(); + } + + @Test + void matchesMetadata() { actual = new DeploymentBuilder(createDeployment()) .editOrNewMetadata() .addToAnnotations("test", "value") @@ -70,9 +121,16 @@ void checksIfDesiredValuesAreTheSame() { .isTrue(); assertThat(GenericKubernetesResourceMatcher - .match(dependentResource, actual, null, context, true).matched()) + .match(dependentResource, actual, null, context, true, true, true).matched()) .withFailMessage("Annotations should matter when metadata is considered") .isFalse(); + + assertThat(GenericKubernetesResourceMatcher + .match(dependentResource, actual, null, context, true, false).matched()) + .withFailMessage( + "Should match when strong equality is not considered and only additive changes are made") + .isTrue(); + } Deployment createDeployment() { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/ServiceStrictMatcherIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/ServiceStrictMatcherIT.java new file mode 100644 index 0000000000..97caab21ee --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/ServiceStrictMatcherIT.java @@ -0,0 +1,57 @@ +package io.javaoperatorsdk.operator; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.sample.servicestrictmatcher.ServiceDependentResource; +import io.javaoperatorsdk.operator.sample.servicestrictmatcher.ServiceStrictMatcherSpec; +import io.javaoperatorsdk.operator.sample.servicestrictmatcher.ServiceStrictMatcherTestCustomResource; +import io.javaoperatorsdk.operator.sample.servicestrictmatcher.ServiceStrictMatcherTestReconciler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class ServiceStrictMatcherIT { + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder().withReconciler(new ServiceStrictMatcherTestReconciler()) + .build(); + + + @Test + void testTheMatchingDoesNoTTriggersFurtherUpdates() { + var resource = operator.create(testResource()); + + await().untilAsserted(() -> { + assertThat(operator.getReconcilerOfType(ServiceStrictMatcherTestReconciler.class) + .getNumberOfExecutions()).isEqualTo(1); + }); + + // make an update to spec to reconcile again + resource.getSpec().setValue(2); + operator.replace(resource); + + await().pollDelay(Duration.ofMillis(300)).untilAsserted(() -> { + assertThat(operator.getReconcilerOfType(ServiceStrictMatcherTestReconciler.class) + .getNumberOfExecutions()).isEqualTo(2); + assertThat(ServiceDependentResource.updated.get()).isZero(); + }); + } + + + ServiceStrictMatcherTestCustomResource testResource() { + var res = new ServiceStrictMatcherTestCustomResource(); + res.setSpec(new ServiceStrictMatcherSpec()); + res.getSpec().setValue(1); + res.setMetadata(new ObjectMetaBuilder() + .withName("test1") + .build()); + return res; + } + +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/servicestrictmatcher/ServiceDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/servicestrictmatcher/ServiceDependentResource.java new file mode 100644 index 0000000000..1079642b33 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/servicestrictmatcher/ServiceDependentResource.java @@ -0,0 +1,62 @@ +package io.javaoperatorsdk.operator.sample.servicestrictmatcher; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.Service; +import io.javaoperatorsdk.operator.ServiceStrictMatcherIT; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.Matcher; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesResourceMatcher; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; + +import static io.javaoperatorsdk.operator.ReconcilerUtils.loadYaml; + +@KubernetesDependent +public class ServiceDependentResource + extends CRUDKubernetesDependentResource { + + public static AtomicInteger updated = new AtomicInteger(0); + + public ServiceDependentResource() { + super(Service.class); + } + + @Override + protected Service desired(ServiceStrictMatcherTestCustomResource primary, + Context context) { + Service service = loadYaml(Service.class, ServiceStrictMatcherIT.class, "service.yaml"); + service.getMetadata().setName(primary.getMetadata().getName()); + service.getMetadata().setNamespace(primary.getMetadata().getNamespace()); + Map labels = new HashMap<>(); + labels.put("app", "deployment-name"); + service.getSpec().setSelector(labels); + return service; + } + + @Override + public Matcher.Result match(Service actualResource, + ServiceStrictMatcherTestCustomResource primary, + Context context) { + return GenericKubernetesResourceMatcher.match(this, actualResource, primary, context, false, + false, + "/spec/ports", + "/spec/clusterIP", + "/spec/clusterIPs", + "/spec/externalTrafficPolicy", + "/spec/internalTrafficPolicy", + "/spec/ipFamilies", + "/spec/ipFamilyPolicy", + "/spec/sessionAffinity"); + } + + @Override + public Service update(Service actual, Service target, + ServiceStrictMatcherTestCustomResource primary, + Context context) { + updated.addAndGet(1); + return super.update(actual, target, primary, context); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/servicestrictmatcher/ServiceStrictMatcherSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/servicestrictmatcher/ServiceStrictMatcherSpec.java new file mode 100644 index 0000000000..1233b70914 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/servicestrictmatcher/ServiceStrictMatcherSpec.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.operator.sample.servicestrictmatcher; + +public class ServiceStrictMatcherSpec { + + private int value; + + public int getValue() { + return value; + } + + public ServiceStrictMatcherSpec setValue(int value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/servicestrictmatcher/ServiceStrictMatcherTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/servicestrictmatcher/ServiceStrictMatcherTestCustomResource.java new file mode 100644 index 0000000000..6079ceb757 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/servicestrictmatcher/ServiceStrictMatcherTestCustomResource.java @@ -0,0 +1,16 @@ +package io.javaoperatorsdk.operator.sample.servicestrictmatcher; + +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.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("ssm") +public class ServiceStrictMatcherTestCustomResource + extends CustomResource + implements Namespaced { + +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/servicestrictmatcher/ServiceStrictMatcherTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/servicestrictmatcher/ServiceStrictMatcherTestReconciler.java new file mode 100644 index 0000000000..64e81e7c31 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/sample/servicestrictmatcher/ServiceStrictMatcherTestReconciler.java @@ -0,0 +1,29 @@ +package io.javaoperatorsdk.operator.sample.servicestrictmatcher; + +import java.util.concurrent.atomic.AtomicInteger; + +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.dependent.Dependent; + +@ControllerConfiguration(dependents = {@Dependent(type = ServiceDependentResource.class)}) +public class ServiceStrictMatcherTestReconciler + implements Reconciler { + + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + ServiceStrictMatcherTestCustomResource resource, + Context context) { + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/resources/io/javaoperatorsdk/operator/service.yaml b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/service.yaml new file mode 100644 index 0000000000..b9ef3b7b3d --- /dev/null +++ b/operator-framework/src/test/resources/io/javaoperatorsdk/operator/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: "" +spec: + selector: + app: "" + ports: + - protocol: TCP + port: 80 + targetPort: 80 + type: NodePort \ No newline at end of file