diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/HorizontalPodAutoscalerSanitizer.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/HorizontalPodAutoscalerSanitizer.java new file mode 100644 index 0000000000..fd7f18135f --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/HorizontalPodAutoscalerSanitizer.java @@ -0,0 +1,100 @@ +/* + * 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.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +import io.fabric8.kubernetes.api.model.GenericKubernetesResource; +import io.fabric8.kubernetes.api.model.autoscaling.v2.HorizontalPodAutoscalerSpec; +import io.fabric8.kubernetes.api.model.autoscaling.v2.MetricSpec; +import io.fabric8.kubernetes.api.model.autoscaling.v2.MetricTarget; + +/** + * Sanitizes the target quantities ({@code value} and {@code averageValue}) of the metrics in a pair + * of {@link HorizontalPodAutoscalerSpec} instances. + * + *

When the number of metrics differs, the sanitizer returns early without fixing the actual map, + * because the specs differ anyway. A metric source that is only present on one side, for example + * because the metric type differs, is skipped for the same reason. + */ +class HorizontalPodAutoscalerSanitizer { + + private static final Map> TARGET_BY_METRIC_SOURCE = + Map.of( + "external", m -> m.getExternal() == null ? null : m.getExternal().getTarget(), + "object", m -> m.getObject() == null ? null : m.getObject().getTarget(), + "pods", m -> m.getPods() == null ? null : m.getPods().getTarget(), + "resource", m -> m.getResource() == null ? null : m.getResource().getTarget(), + "containerResource", + m -> m.getContainerResource() == null ? null : m.getContainerResource().getTarget()); + + static void sanitizeHorizontalPodAutoscalerSpec( + final Map actualMap, + final HorizontalPodAutoscalerSpec actualSpec, + final HorizontalPodAutoscalerSpec desiredSpec) { + if (actualSpec == null || desiredSpec == null) { + return; + } + final List actualMetrics = actualSpec.getMetrics(); + final List desiredMetrics = desiredSpec.getMetrics(); + if (actualMetrics == null + || desiredMetrics == null + || actualMetrics.size() != desiredMetrics.size()) { + return; + } + for (int metricIndex = 0; metricIndex < desiredMetrics.size(); metricIndex++) { + final var actualMetric = actualMetrics.get(metricIndex); + final var desiredMetric = desiredMetrics.get(metricIndex); + for (var source : TARGET_BY_METRIC_SOURCE.entrySet()) { + sanitizeTarget( + actualMap, + metricIndex, + source.getKey(), + source.getValue().apply(actualMetric), + source.getValue().apply(desiredMetric)); + } + } + } + + @SuppressWarnings("unchecked") + private static void sanitizeTarget( + final Map actualMap, + final int metricIndex, + final String metricSource, + final MetricTarget actualTarget, + final MetricTarget desiredTarget) { + if (actualTarget == null || desiredTarget == null) { + return; + } + Optional.ofNullable( + GenericKubernetesResource.get( + actualMap, "spec", "metrics", metricIndex, metricSource, "target")) + .map(Map.class::cast) + .ifPresent( + m -> { + QuantitySanitizer.sanitizeQuantity( + m, "value", actualTarget.getValue(), desiredTarget.getValue()); + QuantitySanitizer.sanitizeQuantity( + m, + "averageValue", + actualTarget.getAverageValue(), + desiredTarget.getAverageValue()); + }); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/PodTemplateSpecSanitizer.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/PodTemplateSpecSanitizer.java index 9b6375c6a8..26ebaf2823 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/PodTemplateSpecSanitizer.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/PodTemplateSpecSanitizer.java @@ -139,26 +139,7 @@ private static void sanitizeQuantities( "resources", quantityPath)) .map(Map.class::cast) - .ifPresent( - m -> - actualResource.forEach( - (key, actualQuantity) -> { - final var desiredQuantity = desiredResource.get(key); - if (desiredQuantity == null) { - return; - } - // check if the string representation of the Quantity instances is equal - if (actualQuantity.getAmount().equals(desiredQuantity.getAmount()) - && actualQuantity.getFormat().equals(desiredQuantity.getFormat())) { - return; - } - // check if the numerical amount of the Quantity instances is equal - if (actualQuantity.equals(desiredQuantity)) { - // replace the actual Quantity with the desired Quantity to prevent a - // resource update - m.replace(key, desiredQuantity.toString()); - } - })); + .ifPresent(m -> QuantitySanitizer.sanitizeQuantities(m, actualResource, desiredResource)); } @SuppressWarnings("unchecked") diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/QuantitySanitizer.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/QuantitySanitizer.java new file mode 100644 index 0000000000..46fb18320e --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/QuantitySanitizer.java @@ -0,0 +1,61 @@ +/* + * 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.Map; +import java.util.Objects; + +import io.fabric8.kubernetes.api.model.Quantity; + +/** + * Replaces an actual {@link Quantity} with the desired one when both are numerically equal. The API + * server stores quantities in canonical form (for example {@code 1000} as {@code 1k}), which would + * otherwise cause a mismatch. + */ +class QuantitySanitizer { + + static void sanitizeQuantity( + final Map parent, + final String key, + final Quantity actualQuantity, + final Quantity desiredQuantity) { + if (actualQuantity == null || desiredQuantity == null) { + return; + } + // check if the string representation of the Quantity instances is equal + if (Objects.equals(actualQuantity.getAmount(), desiredQuantity.getAmount()) + && Objects.equals(actualQuantity.getFormat(), desiredQuantity.getFormat())) { + return; + } + // check if the numerical amount of the Quantity instances is equal + if (actualQuantity.equals(desiredQuantity)) { + // replace the actual Quantity with the desired Quantity to prevent a resource update + parent.replace(key, desiredQuantity.toString()); + } + } + + static void sanitizeQuantities( + final Map parent, + final Map actualQuantities, + final Map desiredQuantities) { + if (actualQuantities == null || desiredQuantities == null) { + return; + } + actualQuantities.forEach( + (key, actualQuantity) -> + sanitizeQuantity(parent, key, actualQuantity, desiredQuantities.get(key))); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcher.java index abec13290d..1945609079 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcher.java @@ -39,6 +39,7 @@ import io.fabric8.kubernetes.api.model.apps.ReplicaSet; import io.fabric8.kubernetes.api.model.apps.StatefulSet; import io.fabric8.kubernetes.api.model.apps.StatefulSetSpec; +import io.fabric8.kubernetes.api.model.autoscaling.v2.HorizontalPodAutoscaler; import io.fabric8.kubernetes.client.utils.KubernetesSerialization; import io.javaoperatorsdk.operator.OperatorException; import io.javaoperatorsdk.operator.api.reconciler.Context; @@ -47,6 +48,7 @@ import com.github.difflib.DiffUtils; import com.github.difflib.UnifiedDiffUtils; +import static io.javaoperatorsdk.operator.processing.dependent.kubernetes.HorizontalPodAutoscalerSanitizer.sanitizeHorizontalPodAutoscalerSpec; import static io.javaoperatorsdk.operator.processing.dependent.kubernetes.PodTemplateSpecSanitizer.sanitizePodTemplateSpec; /** @@ -220,9 +222,16 @@ protected void sanitizeState(R actual, R desired, Map actualMap) actualMap, actualDaemonSet.getSpec().getTemplate(), desiredDaemonSet.getSpec().getTemplate()); + } else if (actual instanceof HorizontalPodAutoscaler actualHorizontalPodAutoscaler + && desired instanceof HorizontalPodAutoscaler desiredHorizontalPodAutoscaler) { + sanitizeHorizontalPodAutoscalerSpec( + actualMap, + actualHorizontalPodAutoscaler.getSpec(), + desiredHorizontalPodAutoscaler.getSpec()); } } + @SuppressWarnings("unchecked") private static void sanitizeVolumeClaimTemplates( Map actualMap, StatefulSetSpec actualSpec, StatefulSetSpec desiredSpec) { int claims = desiredSpec.getVolumeClaimTemplates().size(); @@ -243,6 +252,18 @@ private static void sanitizeVolumeClaimTemplates( .map(Map.class::cast) .ifPresent(m -> m.remove("status")); } + var desiredResources = claim.getSpec().getResources(); + var actualResources = actualSpec.getVolumeClaimTemplates().get(i).getSpec().getResources(); + if (desiredResources != null && actualResources != null) { + Optional.ofNullable( + GenericKubernetesResource.get( + actualMap, "spec", "volumeClaimTemplates", i, "spec", "resources", "requests")) + .map(Map.class::cast) + .ifPresent( + m -> + QuantitySanitizer.sanitizeQuantities( + m, actualResources.getRequests(), desiredResources.getRequests())); + } } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcherTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcherTest.java index c4d2f2c77d..eb7e54f492 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcherTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/SSABasedGenericKubernetesResourceMatcherTest.java @@ -25,11 +25,13 @@ import io.fabric8.kubernetes.api.model.ConfigMap; import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.Quantity; import io.fabric8.kubernetes.api.model.Secret; import io.fabric8.kubernetes.api.model.apps.DaemonSet; import io.fabric8.kubernetes.api.model.apps.Deployment; import io.fabric8.kubernetes.api.model.apps.ReplicaSet; import io.fabric8.kubernetes.api.model.apps.StatefulSet; +import io.fabric8.kubernetes.api.model.autoscaling.v2.HorizontalPodAutoscaler; import io.javaoperatorsdk.operator.MockKubernetesClient; import io.javaoperatorsdk.operator.OperatorException; import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; @@ -251,7 +253,8 @@ void withFinalizer() { strings = { "sample-sts-volumeclaimtemplates-desired.yaml", "sample-sts-volumeclaimtemplates-desired-with-status.yaml", - "sample-sts-volumeclaimtemplates-desired-with-volumemode.yaml" + "sample-sts-volumeclaimtemplates-desired-with-volumemode.yaml", + "sample-sts-volumeclaimtemplates-desired-with-equivalent-storage.yaml" }) void testSanitizeState_statefulSetWithVolumeClaims(String desiredResourceFileName) { var desiredStatefulSet = loadResource(desiredResourceFileName, StatefulSet.class); @@ -358,6 +361,31 @@ void testSanitizeState_daemonSet_withResourceTypeMismatch() { assertThat(matcher.matches(actualDaemonSet, desiredReplicaSet, mockedContext)).isFalse(); } + @Test + void testSanitizeState_horizontalPodAutoscalerWithQuantities() { + var desiredHpa = + loadResource("sample-hpa-quantities-desired.yaml", HorizontalPodAutoscaler.class); + var actualHpa = loadResource("sample-hpa-quantities.yaml", HorizontalPodAutoscaler.class); + + assertThat(matcher.matches(actualHpa, desiredHpa, mockedContext)).isTrue(); + } + + @Test + void testSanitizeState_horizontalPodAutoscalerWithQuantities_withMismatch() { + var desiredHpa = + loadResource("sample-hpa-quantities-desired.yaml", HorizontalPodAutoscaler.class); + desiredHpa + .getSpec() + .getMetrics() + .get(0) + .getExternal() + .getTarget() + .setAverageValue(new Quantity("2000")); + var actualHpa = loadResource("sample-hpa-quantities.yaml", HorizontalPodAutoscaler.class); + + assertThat(matcher.matches(actualHpa, desiredHpa, mockedContext)).isFalse(); + } + @ParameterizedTest @ValueSource(booleans = {true, false}) void testCustomMatcher_returnsExpectedMatchBasedOnReadOnlyLabel(boolean readOnly) { diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-hpa-quantities-desired.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-hpa-quantities-desired.yaml new file mode 100644 index 0000000000..da585c5cbc --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-hpa-quantities-desired.yaml @@ -0,0 +1,67 @@ +# +# 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. +# + +# desired HorizontalPodAutoscaler with numerically equal, non-canonical target quantities +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: "test" +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: test + minReplicas: 1 + maxReplicas: 4 + metrics: + - type: External + external: + metric: + name: queue-depth + target: + type: AverageValue + averageValue: "1000" + - type: Object + object: + describedObject: + apiVersion: v1 + kind: Service + name: test + metric: + name: requests-per-second + target: + type: Value + value: "2000m" + - type: Pods + pods: + metric: + name: packets-per-second + target: + type: AverageValue + averageValue: "0.5" + - type: Resource + resource: + name: memory + target: + type: AverageValue + averageValue: "1024Mi" + - type: ContainerResource + containerResource: + name: cpu + container: app + target: + type: AverageValue + averageValue: "1.5" diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-hpa-quantities.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-hpa-quantities.yaml new file mode 100644 index 0000000000..21b5487ce7 --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-hpa-quantities.yaml @@ -0,0 +1,83 @@ +# +# 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. +# + +# actual HorizontalPodAutoscaler with target quantities in the canonical form stored by the API server +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + managedFields: + - manager: controller + operation: Apply + apiVersion: autoscaling/v2 + time: '2026-11-06T10:00:00Z' + fieldsType: FieldsV1 + fieldsV1: + f:spec: + f:maxReplicas: { } + f:metrics: { } + f:minReplicas: { } + f:scaleTargetRef: + f:apiVersion: { } + f:kind: { } + f:name: { } + name: "test" + uid: 0d6f5b8e-2c1a-4f3e-9b7d-5a4c3b2a1f00 +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: test + minReplicas: 1 + maxReplicas: 4 + metrics: + - type: External + external: + metric: + name: queue-depth + target: + type: AverageValue + averageValue: "1k" + - type: Object + object: + describedObject: + apiVersion: v1 + kind: Service + name: test + metric: + name: requests-per-second + target: + type: Value + value: "2" + - type: Pods + pods: + metric: + name: packets-per-second + target: + type: AverageValue + averageValue: "500m" + - type: Resource + resource: + name: memory + target: + type: AverageValue + averageValue: "1Gi" + - type: ContainerResource + containerResource: + name: cpu + container: app + target: + type: AverageValue + averageValue: "1500m" diff --git a/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-with-equivalent-storage.yaml b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-with-equivalent-storage.yaml new file mode 100644 index 0000000000..f8daf9d100 --- /dev/null +++ b/operator-framework-core/src/test/resources/io/javaoperatorsdk/operator/processing/dependent/kubernetes/sample-sts-volumeclaimtemplates-desired-with-equivalent-storage.yaml @@ -0,0 +1,50 @@ +# +# 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. +# + +# desired StatefulSet with a VolumeClaimTemplate with a numerically equal, non-canonical storage quantity +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: "test" +spec: + replicas: 1 + selector: + matchLabels: + app: test-app + serviceName: "nginx-service" + template: + metadata: + labels: + app: test-app + spec: + containers: + - name: nginx + image: nginx:1.17.0 + ports: + - containerPort: 80 + volumeMounts: + - name: persistent-storage + mountPath: /usr/share/nginx/html + volumeClaimTemplates: + - metadata: + name: persistent-storage + spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1024Mi + storageClassName: standard diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/hpadesiredsanitizer/HpaDesiredSanitizerDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/hpadesiredsanitizer/HpaDesiredSanitizerDependentResource.java new file mode 100644 index 0000000000..640614cff5 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/hpadesiredsanitizer/HpaDesiredSanitizerDependentResource.java @@ -0,0 +1,97 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.dependent.hpadesiredsanitizer; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import io.fabric8.kubernetes.api.model.Quantity; +import io.fabric8.kubernetes.api.model.autoscaling.v2.HorizontalPodAutoscaler; +import io.fabric8.kubernetes.api.model.autoscaling.v2.HorizontalPodAutoscalerBuilder; +import io.fabric8.kubernetes.api.model.autoscaling.v2.MetricSpecBuilder; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.dependent.statefulsetdesiredsanitizer.StatefulSetDesiredSanitizerCustomResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; + +public class HpaDesiredSanitizerDependentResource + extends CRUDKubernetesDependentResource< + HorizontalPodAutoscaler, StatefulSetDesiredSanitizerCustomResource> { + + public static final Set matchedPrimaryValues = ConcurrentHashMap.newKeySet(); + public static volatile boolean nonMatchedAtLeastOnce; + + @Override + protected HorizontalPodAutoscaler desired( + StatefulSetDesiredSanitizerCustomResource primary, + Context context) { + var name = primary.getMetadata().getName(); + return new HorizontalPodAutoscalerBuilder() + .withNewMetadata() + .withName(name) + .withNamespace(primary.getMetadata().getNamespace()) + .endMetadata() + .withNewSpec() + .withMinReplicas(1) + .withMaxReplicas(4) + .withNewScaleTargetRef() + .withApiVersion("apps/v1") + .withKind("Deployment") + .withName(name) + .endScaleTargetRef() + .withMetrics( + // the API server stores 1000 as 1k and 0.5 as 500m + new MetricSpecBuilder() + .withType("External") + .withNewExternal() + .withNewMetric() + .withName("queue-depth") + .endMetric() + .withNewTarget() + .withType("Value") + .withValue(new Quantity("1000")) + .endTarget() + .endExternal() + .build(), + new MetricSpecBuilder() + .withType("Pods") + .withNewPods() + .withNewMetric() + .withName("packets-per-second") + .endMetric() + .withNewTarget() + .withType("AverageValue") + .withAverageValue(new Quantity("0.5")) + .endTarget() + .endPods() + .build()) + .endSpec() + .build(); + } + + @Override + public Result match( + HorizontalPodAutoscaler actualResource, + StatefulSetDesiredSanitizerCustomResource primary, + Context context) { + var res = super.match(actualResource, primary, context); + if (res.matched()) { + matchedPrimaryValues.add(primary.getSpec().getValue()); + } else { + nonMatchedAtLeastOnce = true; + } + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/hpadesiredsanitizer/HpaDesiredSanitizerIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/hpadesiredsanitizer/HpaDesiredSanitizerIT.java new file mode 100644 index 0000000000..573d15530b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/hpadesiredsanitizer/HpaDesiredSanitizerIT.java @@ -0,0 +1,91 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.dependent.hpadesiredsanitizer; + +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.fabric8.kubernetes.api.model.autoscaling.v2.HorizontalPodAutoscaler; +import io.javaoperatorsdk.annotation.Sample; +import io.javaoperatorsdk.operator.dependent.statefulsetdesiredsanitizer.StatefulSetDesiredSanitizerCustomResource; +import io.javaoperatorsdk.operator.dependent.statefulsetdesiredsanitizer.StatefulSetDesiredSanitizerSpec; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@Sample( + tldr = "Matching HorizontalPodAutoscaler target quantities with SSA", + description = + """ + Shows that the SSA based matcher treats numerically equal HorizontalPodAutoscaler \ + target quantities as equal, although the API server stores them in canonical form \ + (for example 1000 as 1k), so the HPA is not updated on every reconciliation. + """) +public class HpaDesiredSanitizerIT { + + public static final String TEST_1 = "test1"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new HpaDesiredSanitizerReconciler()) + .build(); + + @Test + void testSSAMatcher() { + var resource = extension.create(testResource()); + + await() + .pollDelay(Duration.ofMillis(200)) + .untilAsserted( + () -> { + var hpa = extension.get(HorizontalPodAutoscaler.class, TEST_1); + assertThat(hpa).isNotNull(); + assertThat( + hpa.getSpec() + .getMetrics() + .get(0) + .getExternal() + .getTarget() + .getValue() + .toString()) + .isEqualTo("1k"); + }); + // make sure the changed primary is reconciled and matched at least once + resource.getSpec().setValue("changed value"); + extension.replace(resource); + + await() + .untilAsserted( + () -> + assertThat(HpaDesiredSanitizerDependentResource.matchedPrimaryValues) + .contains("changed value")); + assertThat(HpaDesiredSanitizerDependentResource.nonMatchedAtLeastOnce).isFalse(); + } + + StatefulSetDesiredSanitizerCustomResource testResource() { + var res = new StatefulSetDesiredSanitizerCustomResource(); + res.setMetadata(new ObjectMetaBuilder().withName(TEST_1).build()); + res.setSpec(new StatefulSetDesiredSanitizerSpec()); + res.getSpec().setValue("initial value"); + + return res; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/hpadesiredsanitizer/HpaDesiredSanitizerReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/hpadesiredsanitizer/HpaDesiredSanitizerReconciler.java new file mode 100644 index 0000000000..cf8ac05aaf --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/hpadesiredsanitizer/HpaDesiredSanitizerReconciler.java @@ -0,0 +1,34 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.dependent.hpadesiredsanitizer; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent; +import io.javaoperatorsdk.operator.dependent.statefulsetdesiredsanitizer.StatefulSetDesiredSanitizerCustomResource; + +@Workflow(dependents = {@Dependent(type = HpaDesiredSanitizerDependentResource.class)}) +@ControllerConfiguration +public class HpaDesiredSanitizerReconciler + implements Reconciler { + + @Override + public UpdateControl reconcile( + StatefulSetDesiredSanitizerCustomResource resource, + Context context) + throws Exception { + return UpdateControl.noUpdate(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerDependentResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerDependentResource.java index e4bcaac460..2c40bedc25 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerDependentResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/statefulsetdesiredsanitizer/StatefulSetDesiredSanitizerDependentResource.java @@ -16,6 +16,7 @@ package io.javaoperatorsdk.operator.dependent.statefulsetdesiredsanitizer; import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Quantity; import io.fabric8.kubernetes.api.model.apps.StatefulSet; import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.api.reconciler.Context; @@ -39,6 +40,15 @@ protected StatefulSet desired( .withName(primary.getMetadata().getName()) .withNamespace(primary.getMetadata().getNamespace()) .build()); + // the API server stores 1024Mi as 1Gi + template + .getSpec() + .getVolumeClaimTemplates() + .get(0) + .getSpec() + .getResources() + .getRequests() + .put("storage", new Quantity("1024Mi")); return template; }