From e3bd7968398c0bef72b45c93b884b8e482794f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 13 Jul 2026 09:35:58 +0200 Subject: [PATCH 01/35] feat: matcher based updates for update control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../matcher/JsonMergePatchMatcher.java | 28 +++++++ .../matcher/JsonMergePatchStatusMatcher.java | 28 +++++++ .../reconciler/matcher/JsonPatchMacher.java | 28 +++++++ .../matcher/JsonPatchStatusMacher.java | 28 +++++++ .../api/reconciler/matcher/Matcher.java | 28 +++++++ .../api/reconciler/matcher/MatcherUtils.java | 82 +++++++++++++++++++ .../api/reconciler/matcher/SSAMatcher.java | 28 +++++++ .../reconciler/matcher/SSAStatusMatcher.java | 28 +++++++ .../api/reconciler/matcher/UpdateMatcher.java | 28 +++++++ .../matcher/UpdateStatusMatcher.java | 28 +++++++ .../GenericKubernetesResourceMatcher.java | 54 ++++++++++++ .../GenericKubernetesResourceMatcherTest.java | 63 ++++++++++++++ 12 files changed, 451 insertions(+) create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchMatcher.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchStatusMatcher.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchMacher.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchStatusMacher.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/Matcher.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/MatcherUtils.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAMatcher.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAStatusMatcher.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateMatcher.java create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateStatusMatcher.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchMatcher.java new file mode 100644 index 0000000000..25e290c87f --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchMatcher.java @@ -0,0 +1,28 @@ +/* + * 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.api.reconciler.matcher; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +public class JsonMergePatchMatcher implements Matcher { + + @Override + public boolean matches(HasMetadata desired, HasMetadata actual, Context context) { + return MatcherUtils.mergePatchMatches( + MatcherUtils.toNode(actual, context), MatcherUtils.toNode(desired, context)); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchStatusMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchStatusMatcher.java new file mode 100644 index 0000000000..4c29948022 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchStatusMatcher.java @@ -0,0 +1,28 @@ +/* + * 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.api.reconciler.matcher; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +public class JsonMergePatchStatusMatcher implements Matcher { + + @Override + public boolean matches(HasMetadata desired, HasMetadata actual, Context context) { + return MatcherUtils.mergePatchMatches( + MatcherUtils.statusNode(actual, context), MatcherUtils.statusNode(desired, context)); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchMacher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchMacher.java new file mode 100644 index 0000000000..c89dc92a97 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchMacher.java @@ -0,0 +1,28 @@ +/* + * 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.api.reconciler.matcher; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +public class JsonPatchMacher implements Matcher { + + @Override + public boolean matches(HasMetadata desired, HasMetadata actual, Context context) { + return MatcherUtils.jsonPatchMatches( + MatcherUtils.toNode(actual, context), MatcherUtils.toNode(desired, context)); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchStatusMacher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchStatusMacher.java new file mode 100644 index 0000000000..a48668c535 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchStatusMacher.java @@ -0,0 +1,28 @@ +/* + * 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.api.reconciler.matcher; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +public class JsonPatchStatusMacher implements Matcher { + + @Override + public boolean matches(HasMetadata desired, HasMetadata actual, Context context) { + return MatcherUtils.jsonPatchMatches( + MatcherUtils.statusNode(actual, context), MatcherUtils.statusNode(desired, context)); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/Matcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/Matcher.java new file mode 100644 index 0000000000..5da71ea8eb --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/Matcher.java @@ -0,0 +1,28 @@ +/* + * 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.api.reconciler.matcher; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +public interface Matcher { + + /** + * Matcher if the desired state matches the actual state in respective to underlying update/patch + * method + */ + boolean matches(HasMetadata desired, HasMetadata actual, Context context); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/MatcherUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/MatcherUtils.java new file mode 100644 index 0000000000..a708dbc826 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/MatcherUtils.java @@ -0,0 +1,82 @@ +/* + * 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.api.reconciler.matcher; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.zjsonpatch.JsonDiff; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.NullNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** Shared helpers for the client-side patch based {@link Matcher} implementations. */ +final class MatcherUtils { + + private static final String STATUS = "status"; + + private MatcherUtils() {} + + static JsonNode toNode(HasMetadata resource, Context context) { + return context.getClient().getKubernetesSerialization().convertValue(resource, JsonNode.class); + } + + /** The {@code status} subresource as a node, or a {@link NullNode} if it is not present. */ + static JsonNode statusNode(HasMetadata resource, Context context) { + var status = toNode(resource, context).get(STATUS); + return status == null ? NullNode.getInstance() : status; + } + + /** + * @return {@code true} if applying the desired state as a JSON Patch (RFC 6902) to the actual + * state would be a no-op, i.e. the computed patch contains no operations. + */ + static boolean jsonPatchMatches(JsonNode actual, JsonNode desired) { + return JsonDiff.asJson(actual, desired).isEmpty(); + } + + /** + * @return {@code true} if applying the desired state as a JSON Merge Patch (RFC 7386) to the + * actual state would be a no-op, i.e. every value present in the desired state already equals + * the actual state (additional values only present in the actual state are allowed). + */ + static boolean mergePatchMatches(JsonNode actual, JsonNode desired) { + return applyMergePatch(actual.deepCopy(), desired).equals(actual); + } + + // See https://datatracker.ietf.org/doc/html/rfc7386#section-2 + private static JsonNode applyMergePatch(JsonNode target, JsonNode patch) { + if (!patch.isObject()) { + return patch; + } + var targetObject = + target.isObject() ? (ObjectNode) target : JsonNodeFactory.instance.objectNode(); + var fields = patch.fields(); + while (fields.hasNext()) { + var entry = fields.next(); + if (entry.getValue().isNull()) { + targetObject.remove(entry.getKey()); + } else { + var current = targetObject.get(entry.getKey()); + targetObject.set( + entry.getKey(), + applyMergePatch(current == null ? NullNode.getInstance() : current, entry.getValue())); + } + } + return targetObject; + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAMatcher.java new file mode 100644 index 0000000000..f082b2841e --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAMatcher.java @@ -0,0 +1,28 @@ +/* + * 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.api.reconciler.matcher; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.SSABasedGenericKubernetesResourceMatcher; + +public class SSAMatcher implements Matcher { + + @Override + public boolean matches(HasMetadata desired, HasMetadata actual, Context context) { + return SSABasedGenericKubernetesResourceMatcher.getInstance().matches(actual, desired, context); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAStatusMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAStatusMatcher.java new file mode 100644 index 0000000000..d56ac815ec --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAStatusMatcher.java @@ -0,0 +1,28 @@ +/* + * 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.api.reconciler.matcher; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesResourceMatcher; + +public class SSAStatusMatcher implements Matcher { + + @Override + public boolean matches(HasMetadata desired, HasMetadata actual, Context context) { + return GenericKubernetesResourceMatcher.matchStatus(desired, actual, context).matched(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateMatcher.java new file mode 100644 index 0000000000..e270fe7812 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateMatcher.java @@ -0,0 +1,28 @@ +/* + * 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.api.reconciler.matcher; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesResourceMatcher; + +public class UpdateMatcher implements Matcher { + + @Override + public boolean matches(HasMetadata desired, HasMetadata actual, Context context) { + return GenericKubernetesResourceMatcher.match(desired, actual, context).matched(); + } +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateStatusMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateStatusMatcher.java new file mode 100644 index 0000000000..e9c99acbd3 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateStatusMatcher.java @@ -0,0 +1,28 @@ +/* + * 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.api.reconciler.matcher; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesResourceMatcher; + +public class UpdateStatusMatcher implements Matcher { + + @Override + public boolean matches(HasMetadata desired, HasMetadata actual, Context context) { + return GenericKubernetesResourceMatcher.matchStatus(desired, actual, context).matched(); + } +} 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 5562c883e2..b5a0728e16 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 @@ -20,6 +20,7 @@ import java.util.List; import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.zjsonpatch.DiffFlags; import io.fabric8.zjsonpatch.JsonDiff; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.processing.dependent.Matcher; @@ -29,6 +30,7 @@ public class GenericKubernetesResourceMatcher { private static final String SPEC = "/spec"; + private static final String STATUS = "/status"; private static final String METADATA = "/metadata"; private static final String ADD = "add"; private static final String OP = "op"; @@ -195,6 +197,58 @@ public static Matcher.Result m return Matcher.Result.computed(matched, desired); } + /** + * Determines whether the {@code status} subresource of the specified actual resource matches the + * {@code status} of the specified desired resource. Unlike {@link #match(HasMetadata, + * HasMetadata, Context)}, which ignores the status, this method considers only the + * {@code /status} subtree; all other differences (spec, metadata, ...) are ignored. + * + * @param desired the desired resource + * @param actualResource the actual resource + * @param context the {@link Context} instance within which this method is called + * @param resource + * @return results of matching + */ + public static Matcher.Result matchStatus( + R desired, R actualResource, Context

context) { + return matchStatus(desired, actualResource, false, context); + } + + /** + * Determines whether the {@code status} subresource of the specified actual resource matches the + * {@code status} of the specified desired resource. Unlike {@link #match(HasMetadata, + * HasMetadata, Context)}, which ignores the status, this method considers only the + * {@code /status} subtree; all other differences (spec, metadata, ...) are ignored. + * + * @param desired the desired resource + * @param actualResource the actual resource + * @param valuesEquality if {@code false}, the algorithm only checks that the values present in + * the desired status are the same in the actual status, allowing the actual status to contain + * additional values (for example defaults added by the server). If {@code true}, the statuses + * match only if they are fully equal. + * @param context the {@link Context} instance within which this method is called + * @param resource + * @return results of matching + */ + public static Matcher.Result matchStatus( + R desired, R actualResource, boolean valuesEquality, Context

context) { + final var kubernetesSerialization = context.getClient().getKubernetesSerialization(); + var desiredNode = kubernetesSerialization.convertValue(desired, JsonNode.class); + var actualNode = kubernetesSerialization.convertValue(actualResource, JsonNode.class); + var wholeDiffJsonPatch = + JsonDiff.asJson(desiredNode, actualNode, DiffFlags.dontNormalizeOpIntoMoveAndCopy()); + + boolean matched = true; + for (int i = 0; i < wholeDiffJsonPatch.size() && matched; i++) { + var node = wholeDiffJsonPatch.get(i); + if (nodeIsChildOf(node, List.of(STATUS))) { + matched = match(valuesEquality, node, Collections.emptyList()); + } + } + + return Matcher.Result.computed(matched, desired); + } + private static boolean match(boolean equality, JsonNode diff, final List ignoreList) { if (equality) { return false; 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 8dd7283fb9..0c2583d594 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 @@ -190,6 +190,69 @@ void matchConfigMap() { assertThat(match.matched()).isTrue(); } + @Test + void matchStatusMatchesEqualStatus() { + var desiredWithStatus = createDeployment(); + desiredWithStatus.setStatus(new DeploymentStatusBuilder().withReplicas(1).build()); + var actualWithStatus = createDeployment(); + actualWithStatus.setStatus(new DeploymentStatusBuilder().withReplicas(1).build()); + assertThat( + GenericKubernetesResourceMatcher.matchStatus( + desiredWithStatus, actualWithStatus, context) + .matched()) + .isTrue(); + } + + @Test + void matchStatusIgnoresNonStatusChanges() { + var desiredWithStatus = createDeployment(); + desiredWithStatus.getSpec().setReplicas(5); + desiredWithStatus.setStatus(new DeploymentStatusBuilder().withReplicas(1).build()); + var actualWithStatus = createDeployment(); + actualWithStatus.setStatus(new DeploymentStatusBuilder().withReplicas(1).build()); + assertThat( + GenericKubernetesResourceMatcher.matchStatus( + desiredWithStatus, actualWithStatus, context) + .matched()) + .withFailMessage("Only the status subresource should be considered") + .isTrue(); + } + + @Test + void matchStatusDoesNotMatchDifferentStatus() { + var desiredWithStatus = createDeployment(); + desiredWithStatus.setStatus(new DeploymentStatusBuilder().withReplicas(1).build()); + var actualWithStatus = createDeployment(); + actualWithStatus.setStatus(new DeploymentStatusBuilder().withReplicas(2).build()); + assertThat( + GenericKubernetesResourceMatcher.matchStatus( + desiredWithStatus, actualWithStatus, context) + .matched()) + .isFalse(); + } + + @Test + void matchStatusAllowsAdditionalActualStatusValuesByDefault() { + var desiredWithStatus = createDeployment(); + desiredWithStatus.setStatus(new DeploymentStatusBuilder().withReplicas(1).build()); + var actualWithStatus = createDeployment(); + actualWithStatus.setStatus( + new DeploymentStatusBuilder().withReplicas(1).withAvailableReplicas(1).build()); + assertThat( + GenericKubernetesResourceMatcher.matchStatus( + desiredWithStatus, actualWithStatus, context) + .matched()) + .withFailMessage( + "Additional status values in the actual state should be allowed by default") + .isTrue(); + assertThat( + GenericKubernetesResourceMatcher.matchStatus( + desiredWithStatus, actualWithStatus, true, context) + .matched()) + .withFailMessage("Additional status values should fail when strong equality is required") + .isFalse(); + } + ConfigMap createConfigMap() { return new ConfigMapBuilder() .withMetadata(new ObjectMetaBuilder().withName("tes1").withNamespace("default").build()) From 8589d61f2d690bc8c5e5a06487d08d991589f152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 9 Jul 2026 14:52:45 +0200 Subject: [PATCH 02/35] test: cocurrent non changing resource reproducer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- ...ChangeDuringStatusPatchCustomResource.java | 29 +++++ .../SpecChangeDuringStatusPatchIT.java | 107 ++++++++++++++++++ ...SpecChangeDuringStatusPatchReconciler.java | 72 ++++++++++++ .../SpecChangeDuringStatusPatchSpec.java | 30 +++++ .../SpecChangeDuringStatusPatchStatus.java | 30 +++++ 5 files changed, 268 insertions(+) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchReconciler.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchSpec.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchStatus.java diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchCustomResource.java new file mode 100644 index 0000000000..dff33b454d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchCustomResource.java @@ -0,0 +1,29 @@ +/* + * 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.baseapi.readcacheafterwrite.specchangeduringstatuspatch; + +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("scdsp") +public class SpecChangeDuringStatusPatchCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchIT.java new file mode 100644 index 0000000000..93caa2fad1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchIT.java @@ -0,0 +1,107 @@ +/* + * 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.baseapi.readcacheafterwrite.specchangeduringstatuspatch; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.dsl.base.PatchContext; +import io.fabric8.kubernetes.client.dsl.base.PatchType; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.specchangeduringstatuspatch.SpecChangeDuringStatusPatchReconciler.STATUS_VALUE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Reproduces a concurrent spec change happening while the controller patches its own status. When + * the reconciler patches the status it opens an event filtering window so it does not re-trigger + * itself. This test changes the spec on the cluster while that window is open and verifies that the + * spec change is still reconciled - it must not be silently absorbed together with the controller's + * own status update. + */ +class SpecChangeDuringStatusPatchIT { + + static final String RESOURCE_NAME = "test-resource"; + static final String SPEC_VALUE = "initial"; + public static final String UPDATED_SPEC_VALUE = "updated-val"; + + SpecChangeDuringStatusPatchReconciler reconciler = new SpecChangeDuringStatusPatchReconciler(); + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder().withReconciler(reconciler).build(); + + @RepeatedTest(10) + void specChangeDuringStatusPatchIsReconciled() throws InterruptedException { + var res = extension.create(testResource()); + var statusRes = testResource(); + statusRes.getMetadata().setNamespace(res.getMetadata().getNamespace()); + extension + .getKubernetesClient() + .resource(statusRes) + .status() + .patch( + new PatchContext.Builder() + .withForce(true) + .withFieldManager( + SpecChangeDuringStatusPatchReconciler.class.getSimpleName().toLowerCase()) + .withPatchType(PatchType.SERVER_SIDE_APPLY) + .build()); + + // wait until the reconciler is inside its own status patch, holding the filtering window open + assertThat(reconciler.statusPatchStartedLatch.await(30, TimeUnit.SECONDS)) + .as("reconciler should enter its own status patch operation") + .isTrue(); + + // change the spec on the cluster while the controller's status patch is still in flight + var current = extension.get(SpecChangeDuringStatusPatchCustomResource.class, RESOURCE_NAME); + current.getSpec().setValue(UPDATED_SPEC_VALUE); + extension.replace(current); + + // let the reconciler finish its own status patch + reconciler.specChangeDoneLatch.countDown(); + + // the spec change must be picked up by a fresh reconciliation and not lost with the own update + await() + .atMost(Duration.ofSeconds(5)) + .untilAsserted( + () -> { + assertThat(reconciler.numberOfExecutions.get()).isGreaterThanOrEqualTo(2); + assertThat(reconciler.lastObservedSpecValue.get()) + .as("a later reconciliation must observe the externally-applied spec change") + .isEqualTo(UPDATED_SPEC_VALUE); + }); + + // sanity check: the status the controller set is still present after the concurrent spec change + var updated = extension.get(SpecChangeDuringStatusPatchCustomResource.class, RESOURCE_NAME); + assertThat(updated.getSpec().getValue()).isEqualTo(UPDATED_SPEC_VALUE); + assertThat(updated.getStatus()).isNotNull(); + assertThat(updated.getStatus().getValue()).isEqualTo(STATUS_VALUE); + } + + SpecChangeDuringStatusPatchCustomResource testResource() { + var r = new SpecChangeDuringStatusPatchCustomResource(); + r.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build()); + r.setSpec(new SpecChangeDuringStatusPatchSpec().setValue(SPEC_VALUE)); + r.setStatus(new SpecChangeDuringStatusPatchStatus().setValue(STATUS_VALUE)); + return r; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchReconciler.java new file mode 100644 index 0000000000..33d809046b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchReconciler.java @@ -0,0 +1,72 @@ +/* + * 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.baseapi.readcacheafterwrite.specchangeduringstatuspatch; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +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; + +/** + * On the first reconciliation the reconciler patches its own status, but keeps the event filtering + * window (opened by {@link + * io.javaoperatorsdk.operator.api.reconciler.ResourceOperations#resourcePatch}) open until the test + * signals that it has changed the spec on the cluster. This reproduces the race where a spec change + * lands while the controller's own status patch is in flight: the spec change event must still + * propagate as a fresh reconciliation, it must not be absorbed as if it were our own status update. + */ +@ControllerConfiguration +public class SpecChangeDuringStatusPatchReconciler + implements Reconciler { + + static final String STATUS_VALUE = "reconciled"; + + final AtomicInteger numberOfExecutions = new AtomicInteger(); + final CountDownLatch statusPatchStartedLatch = new CountDownLatch(1); + final CountDownLatch specChangeDoneLatch = new CountDownLatch(1); + final AtomicReference lastObservedSpecValue = new AtomicReference<>(); + + @Override + public UpdateControl reconcile( + SpecChangeDuringStatusPatchCustomResource resource, + Context context) { + int execution = numberOfExecutions.incrementAndGet(); + lastObservedSpecValue.set(resource.getSpec().getValue()); + + if (execution == 1) { + resource.setStatus(new SpecChangeDuringStatusPatchStatus().setValue(STATUS_VALUE)); + resource.getMetadata().setResourceVersion(null); + // Patch our own status, but hold the filtering window open with a hook that lets the test + // change the spec on the cluster WHILE the status patch is still in flight. + statusPatchStartedLatch.countDown(); + try { + if (!specChangeDoneLatch.await(30, TimeUnit.SECONDS)) { + throw new IllegalStateException("timed out waiting for external spec change"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + return UpdateControl.patchStatus(resource); + } + return UpdateControl.noUpdate(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchSpec.java new file mode 100644 index 0000000000..64a6e7f0ef --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchSpec.java @@ -0,0 +1,30 @@ +/* + * 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.baseapi.readcacheafterwrite.specchangeduringstatuspatch; + +public class SpecChangeDuringStatusPatchSpec { + + private String value; + + public String getValue() { + return value; + } + + public SpecChangeDuringStatusPatchSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchStatus.java new file mode 100644 index 0000000000..4cb857a4cc --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchStatus.java @@ -0,0 +1,30 @@ +/* + * 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.baseapi.readcacheafterwrite.specchangeduringstatuspatch; + +public class SpecChangeDuringStatusPatchStatus { + + private String value; + + public String getValue() { + return value; + } + + public SpecChangeDuringStatusPatchStatus setValue(String value) { + this.value = value; + return this; + } +} From ddd577f4219f396986b1e0f95278c77da0a7b2b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 10 Jul 2026 12:01:39 +0200 Subject: [PATCH 03/35] fix: event filtering edge case with no-op updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../informer/ManagedInformerEventSource.java | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java index 5a239a7377..58d04de0c2 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java @@ -94,14 +94,20 @@ public void changeNamespaces(Set namespaces) { * reconciliation. */ @SuppressWarnings("unchecked") - public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator updateMethod) { + public R eventFilteringUpdateAndCacheResource( + R resourceToUpdate, UnaryOperator updateOperation) { + if (resourceToUpdate.getMetadata().getResourceVersion() == null) { + log.debug("No resourceVersion set. Skipping event filtering."); + return updateAndCacheResource(resourceToUpdate, updateOperation); + } + ResourceID id = ResourceID.fromResource(resourceToUpdate); log.debug("Starting event filtering and caching update for id={}", id); R updatedResource = null; Set relatedPrimaryIds = null; try { temporaryResourceCache.startEventFilteringModify(id); - updatedResource = updateMethod.apply(resourceToUpdate); + updatedResource = updateOperation.apply(resourceToUpdate); relatedPrimaryIds = cacheUpdateAndGetRelatedPrimaryIDs(updatedResource, resourceToUpdate); log.debug( "Caching resource update successful. id={}, rv={}", @@ -134,6 +140,12 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator< } } + private R updateAndCacheResource(R resourceToUpdate, UnaryOperator updateOperation) { + var result = updateOperation.apply(resourceToUpdate); + handleRecentResourceUpdate(ResourceID.fromResource(resourceToUpdate), result, resourceToUpdate); + return result; + } + protected abstract void handleEvent( ResourceAction action, R resource, From 992e1e9831dd8f525868cd9c132cf8c8f232e3db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 10 Jul 2026 13:08:28 +0200 Subject: [PATCH 04/35] unit test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../informer/InformerEventSourceTest.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java index c62a1d1a3a..026559b593 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java @@ -780,6 +780,25 @@ void filteringUpdateFallsBackToMapperWhenNoPrimaryToSecondaryIndex() { verify(eventHandlerMock, times(1)).handleEvent(any()); } + @Test + void skipsEventFilteringWhenResourceVersionIsNull() { + // Without a resourceVersion there is nothing to correlate own-write echoes against, so + // event filtering is bypassed: the update is applied and cached directly, no filter window + // is opened and no event is propagated through handleEvent. + var resourceToUpdate = testDeployment(); + resourceToUpdate.getMetadata().setResourceVersion(null); + var updated = deploymentWithResourceVersion(3); + + var result = + informerEventSource.eventFilteringUpdateAndCacheResource(resourceToUpdate, r -> updated); + + assertThat(result).isSameAs(updated); + verify(temporaryResourceCache, times(1)).putResource(updated); + verify(temporaryResourceCache, never()).startEventFilteringModify(any()); + verify(temporaryResourceCache, never()).doneEventFilterModify(any()); + verify(eventHandlerMock, never()).handleEvent(any()); + } + private PrimaryToSecondaryIndex injectIndexMock() throws Exception { @SuppressWarnings("unchecked") PrimaryToSecondaryIndex indexMock = mock(PrimaryToSecondaryIndex.class); From 3969e1560cc9292371f3bf4a72b945517ed9bddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 10 Jul 2026 13:30:31 +0200 Subject: [PATCH 05/35] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/processing/event/ReconciliationDispatcher.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java index 6e7ace0447..18eb986efe 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java @@ -138,8 +138,7 @@ private PostExecutionControl

handleReconcile( } else { updatedResource = context.resourceOperations().addFinalizer(); } - return PostExecutionControl.onlyFinalizerAdded(updatedResource) - .withReSchedule(BaseControl.INSTANT_RESCHEDULE); + return PostExecutionControl.onlyFinalizerAdded(updatedResource); } else { try { return reconcileExecution(executionScope, resourceForExecution, originalResource, context); From e0ed6dfd4aaa7506c1770134c3bdc54a0836de74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 10 Jul 2026 13:31:49 +0200 Subject: [PATCH 06/35] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../event/source/informer/ManagedInformerEventSource.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java index 58d04de0c2..c15cb76989 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java @@ -95,10 +95,10 @@ public void changeNamespaces(Set namespaces) { */ @SuppressWarnings("unchecked") public R eventFilteringUpdateAndCacheResource( - R resourceToUpdate, UnaryOperator updateOperation) { + R resourceToUpdate, UnaryOperator updateMethod) { if (resourceToUpdate.getMetadata().getResourceVersion() == null) { log.debug("No resourceVersion set. Skipping event filtering."); - return updateAndCacheResource(resourceToUpdate, updateOperation); + return updateAndCacheResource(resourceToUpdate, updateMethod); } ResourceID id = ResourceID.fromResource(resourceToUpdate); @@ -107,7 +107,7 @@ public R eventFilteringUpdateAndCacheResource( Set relatedPrimaryIds = null; try { temporaryResourceCache.startEventFilteringModify(id); - updatedResource = updateOperation.apply(resourceToUpdate); + updatedResource = updateMethod.apply(resourceToUpdate); relatedPrimaryIds = cacheUpdateAndGetRelatedPrimaryIDs(updatedResource, resourceToUpdate); log.debug( "Caching resource update successful. id={}, rv={}", From 0fad6af92018109433a0dc208473d17dd57553c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 10 Jul 2026 13:32:11 +0200 Subject: [PATCH 07/35] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../event/source/informer/ManagedInformerEventSource.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java index c15cb76989..ebc5187bd5 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java @@ -94,8 +94,7 @@ public void changeNamespaces(Set namespaces) { * reconciliation. */ @SuppressWarnings("unchecked") - public R eventFilteringUpdateAndCacheResource( - R resourceToUpdate, UnaryOperator updateMethod) { + public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator updateMethod) { if (resourceToUpdate.getMetadata().getResourceVersion() == null) { log.debug("No resourceVersion set. Skipping event filtering."); return updateAndCacheResource(resourceToUpdate, updateMethod); From 6e41c2da915710306b7e153d8872085e2010a02f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 10 Jul 2026 14:48:16 +0200 Subject: [PATCH 08/35] add force filtering option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/reconciler/ResourceOperations.java | 68 ++++++++++++++++--- .../KubernetesDependentResource.java | 7 +- .../informer/ManagedInformerEventSource.java | 14 +++- .../reconciler/ResourceOperationsTest.java | 31 +++++---- .../informer/InformerEventSourceTest.java | 22 ++++++ 5 files changed, 112 insertions(+), 30 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index b9ef475509..8c384fedc4 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -68,6 +68,10 @@ public ResourceOperations(Context

context) { * @param resource type */ public R serverSideApply(R resource) { + return serverSideApply(resource, (Options) null); + } + + public R serverSideApply(R resource, Options options) { return resourcePatch( resource, r -> @@ -79,7 +83,13 @@ public R serverSideApply(R resource) { .withForce(true) .withFieldManager(context.getControllerConfiguration().fieldManager()) .withPatchType(PatchType.SERVER_SIDE_APPLY) - .build())); + .build()), + options); + } + + public R serverSideApply( + R resource, InformerEventSource informerEventSource) { + return serverSideApply(resource, informerEventSource, null); } /** @@ -97,7 +107,7 @@ public R serverSideApply(R resource) { * @param resource type */ public R serverSideApply( - R resource, InformerEventSource informerEventSource) { + R resource, InformerEventSource informerEventSource, Options options) { if (informerEventSource == null) { return serverSideApply(resource); } @@ -208,6 +218,10 @@ public P serverSideApplyPrimaryStatus(P resource) { context.eventSourceRetriever().getControllerEventSource()); } + public R update(R resource) { + return update(resource, (Options) null); + } + /** * Updates the resource and caches the response if needed, thus making sure that next * reconciliation will see to updated resource - or more recent one if additional update happened @@ -221,8 +235,13 @@ public P serverSideApplyPrimaryStatus(P resource) { * @return updated resource * @param resource type */ - public R update(R resource) { - return resourcePatch(resource, r -> context.getClient().resource(r).update()); + public R update(R resource, Options options) { + return resourcePatch(resource, r -> context.getClient().resource(r).update(), options); + } + + public R update( + R resource, InformerEventSource informerEventSource) { + return update(resource, informerEventSource, null); } /** @@ -240,12 +259,12 @@ public R update(R resource) { * @param resource type */ public R update( - R resource, InformerEventSource informerEventSource) { + R resource, InformerEventSource informerEventSource, Options options) { if (informerEventSource == null) { return update(resource); } return resourcePatch( - resource, r -> context.getClient().resource(r).update(), informerEventSource); + resource, r -> context.getClient().resource(r).update(), informerEventSource, options); } /** @@ -262,7 +281,8 @@ public R update( * @param resource type */ public R create(R resource) { - return resourcePatch(resource, r -> context.getClient().resource(r).create()); + return resourcePatch( + resource, r -> context.getClient().resource(r).create(), new Options(true)); } /** @@ -284,8 +304,12 @@ public R create( if (informerEventSource == null) { return create(resource); } + // it is safe to do event filtering for create since check if the resource already exists. return resourcePatch( - resource, r -> context.getClient().resource(r).create(), informerEventSource); + resource, + r -> context.getClient().resource(r).create(), + informerEventSource, + new Options(true)); } /** @@ -518,6 +542,10 @@ public P jsonMergePatchPrimaryStatus(P resource) { context.eventSourceRetriever().getControllerEventSource()); } + public R resourcePatch(R resource, UnaryOperator updateOperation) { + return resourcePatch(resource, updateOperation, (Options) null); + } + /** * Utility method to patch a resource and cache the result. Automatically discovers the event * source for the resource type and delegates to {@link #resourcePatch(HasMetadata, UnaryOperator, @@ -530,7 +558,8 @@ public P jsonMergePatchPrimaryStatus(P resource) { * @throws IllegalStateException if no event source or multiple event sources are found */ @SuppressWarnings({"rawtypes", "unchecked"}) - public R resourcePatch(R resource, UnaryOperator updateOperation) { + public R resourcePatch( + R resource, UnaryOperator updateOperation, Options options) { var esList = context.eventSourceRetriever().getEventSourcesFor(resource.getClass()); if (esList.isEmpty()) { @@ -544,7 +573,8 @@ public R resourcePatch(R resource, UnaryOperator upda es.name()); } if (es instanceof ManagedInformerEventSource mes) { - return resourcePatch(resource, updateOperation, (ManagedInformerEventSource) mes); + return resourcePatch( + resource, updateOperation, (ManagedInformerEventSource) mes, options); } else { throw new IllegalStateException( "Target event source must be a subclass off " @@ -565,7 +595,16 @@ public R resourcePatch(R resource, UnaryOperator upda */ public R resourcePatch( R resource, UnaryOperator updateOperation, ManagedInformerEventSource ies) { - return ies.eventFilteringUpdateAndCacheResource(resource, updateOperation); + return resourcePatch(resource, updateOperation, ies, (Options) null); + } + + public R resourcePatch( + R resource, + UnaryOperator updateOperation, + ManagedInformerEventSource ies, + Options options) { + return ies.eventFilteringUpdateAndCacheResource( + resource, updateOperation, options != null && options.forceEventFiltering()); } /** @@ -754,4 +793,11 @@ public P addFinalizerWithSSA(String finalizerName) { e); } } + + @Experimental("This API might change") + /** + * Force filtering only if it is made sure that the update not results on a no-op change. See + * details here: https://github.com/operator-framework/java-operator-sdk/pull/3484 + */ + public record Options(boolean forceEventFiltering) {} } 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 f8d7c07b01..b69cf933a4 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 @@ -30,6 +30,7 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; import io.javaoperatorsdk.operator.api.reconciler.Ignore; +import io.javaoperatorsdk.operator.api.reconciler.ResourceOperations; import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected; import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ConfiguredDependentResource; import io.javaoperatorsdk.operator.processing.GroupVersionKind; @@ -90,7 +91,6 @@ public R create(R desired, P primary, Context

context) { desired.getClass(), ResourceID.fromResource(desired), ssa); - return ssa ? context.resourceOperations().serverSideApply(desired, eventSource().orElse(null)) : context.resourceOperations().create(desired, eventSource().orElse(null)); @@ -114,7 +114,10 @@ public R update(R actual, R desired, P primary, Context

context) { ssa); if (ssa) { updatedResource = - context.resourceOperations().serverSideApply(desired, eventSource().orElse(null)); + context + .resourceOperations() + .serverSideApply( + desired, eventSource().orElse(null), new ResourceOperations.Options(true)); } else { var updatedActual = GenericResourceUpdater.updateResource(actual, desired, context); updatedResource = diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java index ebc5187bd5..9739a6d163 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java @@ -39,6 +39,7 @@ import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.config.Informable; import io.javaoperatorsdk.operator.api.config.NamespaceChangeable; +import io.javaoperatorsdk.operator.api.reconciler.Experimental; import io.javaoperatorsdk.operator.api.reconciler.dependent.RecentOperationCacheFiller; import io.javaoperatorsdk.operator.health.InformerHealthIndicator; import io.javaoperatorsdk.operator.health.InformerWrappingEventSourceHealthIndicator; @@ -88,14 +89,23 @@ public void changeNamespaces(Set namespaces) { } } + @Experimental( + "Internal API. Use ResourceOperation not this directly, this API might change in the future") + public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator updateMethod) { + return eventFilteringUpdateAndCacheResource(resourceToUpdate, updateMethod, false); + } + /** * Updates the resource and makes sure that the response is available for the next reconciliation. * Also makes sure that the even produced by this update is filtered, thus does not trigger the * reconciliation. */ + @Experimental( + "Internal API. Use ResourceOperation not this directly, this API might change in the future") @SuppressWarnings("unchecked") - public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator updateMethod) { - if (resourceToUpdate.getMetadata().getResourceVersion() == null) { + public R eventFilteringUpdateAndCacheResource( + R resourceToUpdate, UnaryOperator updateMethod, boolean forceUpdateFilter) { + if (resourceToUpdate.getMetadata().getResourceVersion() == null && !forceUpdateFilter) { log.debug("No resourceVersion set. Skipping event filtering."); return updateAndCacheResource(resourceToUpdate, updateMethod); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java index 8d0176cd4a..abc4326dd1 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java @@ -84,7 +84,7 @@ void addsFinalizer() { // Mock successful finalizer addition when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class))) + any(), any(UnaryOperator.class), anyBoolean())) .thenAnswer( invocation -> { var res = TestUtils.testCustomResource1(); @@ -99,7 +99,7 @@ void addsFinalizer() { assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); verify(controllerEventSource, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); } @Test @@ -111,7 +111,7 @@ void addsFinalizerWithSSA() { // Mock successful SSA finalizer addition when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class))) + any(), any(UnaryOperator.class), anyBoolean())) .thenAnswer( invocation -> { var res = TestUtils.testCustomResource1(); @@ -126,7 +126,7 @@ void addsFinalizerWithSSA() { assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); verify(controllerEventSource, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); } @Test @@ -139,7 +139,7 @@ void removesFinalizer() { // Mock successful finalizer removal when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class))) + any(), any(UnaryOperator.class), anyBoolean())) .thenAnswer( invocation -> { var res = TestUtils.testCustomResource1(); @@ -154,7 +154,7 @@ void removesFinalizer() { assertThat(result.hasFinalizer(FINALIZER_NAME)).isFalse(); assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); verify(controllerEventSource, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); } @Test @@ -166,7 +166,7 @@ void retriesAddingFinalizerWithoutSSA() { // First call throws conflict, second succeeds when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class))) + any(), any(UnaryOperator.class), anyBoolean())) .thenThrow(new KubernetesClientException("Conflict", 409, null)) .thenAnswer( invocation -> { @@ -184,7 +184,7 @@ void retriesAddingFinalizerWithoutSSA() { assertThat(result).isNotNull(); assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); verify(controllerEventSource, times(2)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); verify(resourceOp, times(1)).get(); } @@ -198,7 +198,7 @@ void nullResourceIsGracefullyHandledOnFinalizerRemovalRetry() { // First call throws conflict when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class))) + any(), any(UnaryOperator.class), anyBoolean())) .thenThrow(new KubernetesClientException("Conflict", 409, null)); // Return null on retry (resource was deleted) @@ -207,7 +207,7 @@ void nullResourceIsGracefullyHandledOnFinalizerRemovalRetry() { resourceOperations.removeFinalizer(FINALIZER_NAME); verify(controllerEventSource, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); verify(resourceOp, times(1)).get(); } @@ -221,7 +221,7 @@ void retriesFinalizerRemovalWithFreshResource() { // First call throws unprocessable (422), second succeeds when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class))) + any(), any(UnaryOperator.class), anyBoolean())) .thenThrow(new KubernetesClientException("Unprocessable", 422, null)) .thenAnswer( invocation -> { @@ -243,7 +243,7 @@ void retriesFinalizerRemovalWithFreshResource() { assertThat(result.getMetadata().getResourceVersion()).isEqualTo("3"); assertThat(result.hasFinalizer(FINALIZER_NAME)).isFalse(); verify(controllerEventSource, times(2)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); verify(resourceOp, times(1)).get(); } @@ -261,7 +261,8 @@ void resourcePatchWithSingleEventSource() { when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) .thenReturn(List.of(managedEventSource)); - when(managedEventSource.eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class))) + when(managedEventSource.eventFilteringUpdateAndCacheResource( + any(), any(UnaryOperator.class), anyBoolean())) .thenReturn(updatedResource); var result = resourceOperations.resourcePatch(resource, UnaryOperator.identity()); @@ -269,7 +270,7 @@ void resourcePatchWithSingleEventSource() { assertThat(result).isNotNull(); assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); verify(managedEventSource, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); } @Test @@ -303,7 +304,7 @@ void resourcePatchUsesFirstEventSourceIfMultipleEventSourcesPresent() { resourceOperations.resourcePatch(resource, UnaryOperator.identity()); verify(eventSource1, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); } @Test diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java index 026559b593..31cbc26d11 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java @@ -799,6 +799,28 @@ void skipsEventFilteringWhenResourceVersionIsNull() { verify(eventHandlerMock, never()).handleEvent(any()); } + @Test + void forceUpdateFilterOpensFilterWindowEvenWhenResourceVersionIsNull() { + // A write without a resourceVersion (e.g. an SSA finalizer add, which uses no optimistic + // locking) would normally skip event filtering. forceUpdateFilter=true forces filtering + // anyway so the resulting own event is correlated and does not trigger a spurious + // reconciliation. + var resourceToUpdate = testDeployment(); + resourceToUpdate.getMetadata().setResourceVersion(null); + var updated = deploymentWithResourceVersion(3); + when(temporaryResourceCache.doneEventFilterModify(any())).thenReturn(Optional.empty()); + + var result = + informerEventSource.eventFilteringUpdateAndCacheResource( + resourceToUpdate, r -> updated, true); + + assertThat(result).isSameAs(updated); + verify(temporaryResourceCache, times(1)).startEventFilteringModify(any()); + verify(temporaryResourceCache, times(1)).doneEventFilterModify(any()); + verify(temporaryResourceCache, times(1)).putResource(updated); + verify(eventHandlerMock, never()).handleEvent(any()); + } + private PrimaryToSecondaryIndex injectIndexMock() throws Exception { @SuppressWarnings("unchecked") PrimaryToSecondaryIndex indexMock = mock(PrimaryToSecondaryIndex.class); From eb7156126adbf4b77f605cafe87e63f790eb8db5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 10 Jul 2026 14:59:19 +0200 Subject: [PATCH 09/35] test fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/reconciler/ResourceOperations.java | 14 +++++++++++--- .../onrelistfilter/OnRelistFilterReconciler.java | 13 ++++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 8c384fedc4..9fdba153ea 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -31,6 +31,7 @@ import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; +import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE; import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getUID; import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getVersion; @@ -71,6 +72,7 @@ public R serverSideApply(R resource) { return serverSideApply(resource, (Options) null); } + @Experimental(API_MIGHT_CHANGE) public R serverSideApply(R resource, Options options) { return resourcePatch( resource, @@ -106,6 +108,7 @@ public R serverSideApply( * @param informerEventSource InformerEventSource to use for resource caching and filtering * @param resource type */ + @Experimental(API_MIGHT_CHANGE) public R serverSideApply( R resource, InformerEventSource informerEventSource, Options options) { if (informerEventSource == null) { @@ -123,7 +126,8 @@ public R serverSideApply( .withFieldManager(context.getControllerConfiguration().fieldManager()) .withPatchType(PatchType.SERVER_SIDE_APPLY) .build()), - informerEventSource); + informerEventSource, + options); } /** @@ -235,6 +239,7 @@ public R update(R resource) { * @return updated resource * @param resource type */ + @Experimental(API_MIGHT_CHANGE) public R update(R resource, Options options) { return resourcePatch(resource, r -> context.getClient().resource(r).update(), options); } @@ -258,6 +263,7 @@ public R update( * @param informerEventSource InformerEventSource to use for resource caching and filtering * @param resource type */ + @Experimental(API_MIGHT_CHANGE) public R update( R resource, InformerEventSource informerEventSource, Options options) { if (informerEventSource == null) { @@ -557,6 +563,7 @@ public R resourcePatch(R resource, UnaryOperator upda * @param resource type * @throws IllegalStateException if no event source or multiple event sources are found */ + @Experimental(API_MIGHT_CHANGE) @SuppressWarnings({"rawtypes", "unchecked"}) public R resourcePatch( R resource, UnaryOperator updateOperation, Options options) { @@ -794,10 +801,11 @@ public P addFinalizerWithSSA(String finalizerName) { } } - @Experimental("This API might change") /** * Force filtering only if it is made sure that the update not results on a no-op change. See - * details here: https://github.com/operator-framework/java-operator-sdk/pull/3484 + * details here: PR + * 3484 */ + @Experimental("This API might change") public record Options(boolean forceEventFiltering) {} } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java index 13e8e72d74..d18ed0ef27 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java @@ -34,6 +34,7 @@ import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.ResourceOperations; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.EventSource; @@ -79,7 +80,10 @@ public UpdateControl reconcile( if (execution == 1) { var cm = prepareConfigMap(resource); switch (mode.get()) { - case NO_RELIST -> context.resourceOperations().serverSideApply(cm, configMapEventSource); + case NO_RELIST -> + context + .resourceOperations() + .serverSideApply(cm, configMapEventSource, new ResourceOperations.Options(true)); case RELIST_AROUND_UPDATE -> { configMapEventSource.simulateOnBeforeList(); var applied = context.resourceOperations().serverSideApply(cm, configMapEventSource); @@ -94,7 +98,9 @@ public UpdateControl reconcile( case RELIST_COMPLETES_BEFORE_UPDATE -> { configMapEventSource.simulateOnBeforeList(); configMapEventSource.simulateOnList(); - context.resourceOperations().serverSideApply(cm, configMapEventSource); + context + .resourceOperations() + .serverSideApply(cm, configMapEventSource, new ResourceOperations.Options(true)); } case RELIST_STARTS_DURING_UPDATE -> { // Drive the event-filtering update path manually so we can fire onBeforeList AFTER the @@ -114,7 +120,8 @@ public UpdateControl reconcile( .withFieldManager(fieldManager) .withPatchType(PatchType.SERVER_SIDE_APPLY) .build()); - }); + }, + true); // See RELIST_AROUND_UPDATE: wait for the own-write event to be buffered while the // re-list is still in progress, so it is tagged as part of the re-list and propagated. configMapEventSource.awaitWatchEventReceived(applied); From 2d31b0f0723648eac5a8aaebca4a461489ea8cc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 10 Jul 2026 17:38:56 +0200 Subject: [PATCH 10/35] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/reconciler/ResourceOperations.java | 42 +++++++++++++++---- .../ChangeNamespaceTestReconciler.java | 4 +- .../OwnSecondaryUpdateReconciler.java | 6 ++- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 9fdba153ea..205d389a95 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -145,7 +145,7 @@ public R serverSideApply( * @return updated resource * @param resource type */ - public R serverSideApplyStatus(R resource) { + public R serverSideApplyStatus(R resource, Options options) { return resourcePatch( resource, r -> @@ -158,7 +158,12 @@ public R serverSideApplyStatus(R resource) { .withForce(true) .withFieldManager(context.getControllerConfiguration().fieldManager()) .withPatchType(PatchType.SERVER_SIDE_APPLY) - .build())); + .build()), + options); + } + + public R serverSideApplyStatus(R resource) { + return serverSideApplyStatus(resource, null); } /** @@ -246,7 +251,7 @@ public R update(R resource, Options options) { public R update( R resource, InformerEventSource informerEventSource) { - return update(resource, informerEventSource, null); + return update(resource, informerEventSource, new Options(true)); } /** @@ -334,7 +339,8 @@ public R create( * @param resource type */ public R updateStatus(R resource) { - return resourcePatch(resource, r -> context.getClient().resource(r).updateStatus()); + return resourcePatch( + resource, r -> context.getClient().resource(r).updateStatus(), new Options(true)); } /** @@ -397,7 +403,13 @@ public P updatePrimaryStatus(P resource) { * @param resource type */ public R jsonPatch(R resource, UnaryOperator unaryOperator) { - return resourcePatch(resource, r -> context.getClient().resource(r).edit(unaryOperator)); + return jsonPatch(resource, unaryOperator, null); + } + + public R jsonPatch( + R resource, UnaryOperator unaryOperator, Options options) { + return resourcePatch( + resource, r -> context.getClient().resource(r).edit(unaryOperator), options); } /** @@ -417,8 +429,14 @@ public R jsonPatch(R resource, UnaryOperator unaryOpe * @return updated resource * @param resource type */ + public R jsonPatchStatus( + R resource, UnaryOperator unaryOperator, Options options) { + return resourcePatch( + resource, r -> context.getClient().resource(r).editStatus(unaryOperator), options); + } + public R jsonPatchStatus(R resource, UnaryOperator unaryOperator) { - return resourcePatch(resource, r -> context.getClient().resource(r).editStatus(unaryOperator)); + return jsonPatchStatus(resource, unaryOperator, null); } /** @@ -482,7 +500,11 @@ public P jsonPatchPrimaryStatus(P resource, UnaryOperator

unaryOperator) { * @param resource type */ public R jsonMergePatch(R resource) { - return resourcePatch(resource, r -> context.getClient().resource(r).patch()); + return jsonMergePatch(resource, null); + } + + public R jsonMergePatch(R resource, Options options) { + return resourcePatch(resource, r -> context.getClient().resource(r).patch(), options); } /** @@ -501,7 +523,11 @@ public R jsonMergePatch(R resource) { * @param resource type */ public R jsonMergePatchStatus(R resource) { - return resourcePatch(resource, r -> context.getClient().resource(r).patchStatus()); + return jsonMergePatchStatus(resource, null); + } + + public R jsonMergePatchStatus(R resource, Options options) { + return resourcePatch(resource, r -> context.getClient().resource(r).patchStatus(), options); } /** diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java index d05364fc44..16cec09eee 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java @@ -53,7 +53,9 @@ public UpdateControl reconcile( ChangeNamespaceTestCustomResource primary, Context context) { - context.resourceOperations().serverSideApply(configMap(primary)); + context + .resourceOperations() + .serverSideApply(configMap(primary), new ResourceOperations.Options(true)); if (primary.getStatus() == null) { primary.setStatus(new ChangeNamespaceTestCustomResourceStatus()); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java index f0ac28b955..b65d540ee2 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java @@ -27,6 +27,7 @@ import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.ResourceOperations; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; import io.javaoperatorsdk.operator.processing.event.source.EventSource; import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; @@ -50,7 +51,10 @@ public UpdateControl reconcile( // version actually advances. With the read-cache-after-write filter in place, none of the // resulting watch events should trigger a fresh reconciliation. for (int i = 1; i <= OWN_SSA_COUNT; i++) { - context.resourceOperations().serverSideApply(prepareCM(resource, i), configMapEventSource); + context + .resourceOperations() + .serverSideApply( + prepareCM(resource, i), configMapEventSource, new ResourceOperations.Options(true)); } return UpdateControl.noUpdate(); } From f4d4c4b402300af2c089a6cd751b098e95689a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sat, 11 Jul 2026 15:24:11 +0200 Subject: [PATCH 11/35] fix: event filtering edge case with no-op updates - extended MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/api/reconciler/Matcher.java | 23 +++ .../api/reconciler/ResourceOperations.java | 175 ++++++++++++++---- .../KubernetesDependentResource.java | 9 +- .../informer/ManagedInformerEventSource.java | 11 +- .../ChangeNamespaceTestReconciler.java | 2 +- .../OnRelistFilterReconciler.java | 6 +- .../OwnSecondaryUpdateReconciler.java | 4 +- 7 files changed, 188 insertions(+), 42 deletions(-) create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Matcher.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Matcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Matcher.java new file mode 100644 index 0000000000..b5d0685245 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Matcher.java @@ -0,0 +1,23 @@ +/* + * 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.api.reconciler; + +import io.fabric8.kubernetes.api.model.HasMetadata; + +public interface Matcher { + + boolean matches(R desired, R actual, Context context); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 205d389a95..594758ecfa 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -166,6 +166,10 @@ public R serverSideApplyStatus(R resource) { return serverSideApplyStatus(resource, null); } + public P serverSideApplyPrimary(P resource) { + return serverSideApplyPrimary(resource, Options.defaultMode()); + } + /** * Server-Side Apply the primary resource. * @@ -180,7 +184,7 @@ public R serverSideApplyStatus(R resource) { * @param resource primary resource for server side apply * @return updated resource */ - public P serverSideApplyPrimary(P resource) { + public P serverSideApplyPrimary(P resource, Options options) { return resourcePatch( resource, r -> @@ -193,7 +197,8 @@ public P serverSideApplyPrimary(P resource) { .withFieldManager(context.getControllerConfiguration().fieldManager()) .withPatchType(PatchType.SERVER_SIDE_APPLY) .build()), - context.eventSourceRetriever().getControllerEventSource()); + context.eventSourceRetriever().getControllerEventSource(), + options); } /** @@ -211,6 +216,10 @@ public P serverSideApplyPrimary(P resource) { * @return updated resource */ public P serverSideApplyPrimaryStatus(P resource) { + return serverSideApplyPrimaryStatus(resource, Options.defaultMode()); + } + + public P serverSideApplyPrimaryStatus(P resource, Options options) { return resourcePatch( resource, r -> @@ -224,11 +233,12 @@ public P serverSideApplyPrimaryStatus(P resource) { .withFieldManager(context.getControllerConfiguration().fieldManager()) .withPatchType(PatchType.SERVER_SIDE_APPLY) .build()), - context.eventSourceRetriever().getControllerEventSource()); + context.eventSourceRetriever().getControllerEventSource(), + options); } public R update(R resource) { - return update(resource, (Options) null); + return update(resource, Options.defaultMode()); } /** @@ -251,7 +261,7 @@ public R update(R resource, Options options) { public R update( R resource, InformerEventSource informerEventSource) { - return update(resource, informerEventSource, new Options(true)); + return update(resource, informerEventSource, Options.defaultMode()); } /** @@ -292,8 +302,12 @@ public R update( * @param resource type */ public R create(R resource) { - return resourcePatch( - resource, r -> context.getClient().resource(r).create(), new Options(true)); + // it is safe to do event filtering for create since check if the resource already exists. + return create(resource, Options.forcedFiltering()); + } + + public R create(R resource, Options options) { + return resourcePatch(resource, r -> context.getClient().resource(r).create(), options); } /** @@ -311,16 +325,13 @@ public R create(R resource) { * @param resource type */ public R create( - R resource, InformerEventSource informerEventSource) { + R resource, InformerEventSource informerEventSource, Options options) { if (informerEventSource == null) { return create(resource); } // it is safe to do event filtering for create since check if the resource already exists. return resourcePatch( - resource, - r -> context.getClient().resource(r).create(), - informerEventSource, - new Options(true)); + resource, r -> context.getClient().resource(r).create(), informerEventSource, options); } /** @@ -339,8 +350,11 @@ public R create( * @param resource type */ public R updateStatus(R resource) { - return resourcePatch( - resource, r -> context.getClient().resource(r).updateStatus(), new Options(true)); + return updateStatus(resource, Options.defaultMode()); + } + + public R updateStatus(R resource, Options options) { + return resourcePatch(resource, r -> context.getClient().resource(r).updateStatus(), options); } /** @@ -358,10 +372,15 @@ public R updateStatus(R resource) { * @return updated resource */ public P updatePrimary(P resource) { + return updatePrimary(resource, Options.defaultMode()); + } + + public P updatePrimary(P resource, Options options) { return resourcePatch( resource, r -> context.getClient().resource(r).update(), - context.eventSourceRetriever().getControllerEventSource()); + context.eventSourceRetriever().getControllerEventSource(), + options); } /** @@ -412,6 +431,18 @@ public R jsonPatch( resource, r -> context.getClient().resource(r).edit(unaryOperator), options); } + public R jsonPatch( + R resource, + UnaryOperator unaryOperator, + InformerEventSource informerEventSource, + Options options) { + return resourcePatch( + resource, + r -> context.getClient().resource(r).edit(unaryOperator), + informerEventSource, + options); + } + /** * Applies a JSON Patch to the resource status subresource. The unaryOperator function is used to * modify the resource status, and the differences are sent as a JSON Patch. @@ -436,7 +467,19 @@ public R jsonPatchStatus( } public R jsonPatchStatus(R resource, UnaryOperator unaryOperator) { - return jsonPatchStatus(resource, unaryOperator, null); + return jsonPatchStatus(resource, unaryOperator, Options.defaultMode()); + } + + public R jsonPatchStatus( + R resource, + UnaryOperator unaryOperator, + InformerEventSource informerEventSource, + Options options) { + return resourcePatch( + resource, + r -> context.getClient().resource(r).editStatus(unaryOperator), + informerEventSource, + options); } /** @@ -455,10 +498,15 @@ public R jsonPatchStatus(R resource, UnaryOperator un * @return updated resource */ public P jsonPatchPrimary(P resource, UnaryOperator

unaryOperator) { + return jsonPatchPrimary(resource, unaryOperator, Options.defaultMode()); + } + + public P jsonPatchPrimary(P resource, UnaryOperator

unaryOperator, Options options) { return resourcePatch( resource, r -> context.getClient().resource(r).edit(unaryOperator), - context.eventSourceRetriever().getControllerEventSource()); + context.eventSourceRetriever().getControllerEventSource(), + options); } /** @@ -477,10 +525,15 @@ public P jsonPatchPrimary(P resource, UnaryOperator

unaryOperator) { * @return updated resource */ public P jsonPatchPrimaryStatus(P resource, UnaryOperator

unaryOperator) { + return jsonPatchPrimaryStatus(resource, unaryOperator, Options.defaultMode()); + } + + public P jsonPatchPrimaryStatus(P resource, UnaryOperator

unaryOperator, Options options) { return resourcePatch( resource, r -> context.getClient().resource(r).editStatus(unaryOperator), - context.eventSourceRetriever().getControllerEventSource()); + context.eventSourceRetriever().getControllerEventSource(), + options); } /** @@ -500,13 +553,19 @@ public P jsonPatchPrimaryStatus(P resource, UnaryOperator

unaryOperator) { * @param resource type */ public R jsonMergePatch(R resource) { - return jsonMergePatch(resource, null); + return jsonMergePatch(resource, Options.defaultMode()); } public R jsonMergePatch(R resource, Options options) { return resourcePatch(resource, r -> context.getClient().resource(r).patch(), options); } + public R jsonMergePatch( + R resource, InformerEventSource informerEventSource, Options options) { + return resourcePatch( + resource, r -> context.getClient().resource(r).patch(), informerEventSource, options); + } + /** * Applies a JSON Merge Patch to the resource status subresource. * @@ -530,6 +589,12 @@ public R jsonMergePatchStatus(R resource, Options option return resourcePatch(resource, r -> context.getClient().resource(r).patchStatus(), options); } + public R jsonMergePatchStatus( + R resource, InformerEventSource informerEventSource, Options options) { + return resourcePatch( + resource, r -> context.getClient().resource(r).patchStatus(), informerEventSource, options); + } + /** * Applies a JSON Merge Patch to the primary resource. Caches the response using the controller's * event source. @@ -546,10 +611,15 @@ public R jsonMergePatchStatus(R resource, Options option * @return updated resource */ public P jsonMergePatchPrimary(P resource) { + return jsonMergePatchPrimary(resource, Options.defaultMode()); + } + + public P jsonMergePatchPrimary(P resource, Options options) { return resourcePatch( resource, r -> context.getClient().resource(r).patch(), - context.eventSourceRetriever().getControllerEventSource()); + context.eventSourceRetriever().getControllerEventSource(), + options); } /** @@ -568,14 +638,19 @@ public P jsonMergePatchPrimary(P resource) { * @see #jsonMergePatchPrimaryStatus(HasMetadata) */ public P jsonMergePatchPrimaryStatus(P resource) { + return jsonMergePatchPrimaryStatus(resource, Options.defaultMode()); + } + + public P jsonMergePatchPrimaryStatus(P resource, Options options) { return resourcePatch( resource, r -> context.getClient().resource(r).patchStatus(), - context.eventSourceRetriever().getControllerEventSource()); + context.eventSourceRetriever().getControllerEventSource(), + options); } public R resourcePatch(R resource, UnaryOperator updateOperation) { - return resourcePatch(resource, updateOperation, (Options) null); + return resourcePatch(resource, updateOperation, Options.defaultMode()); } /** @@ -636,8 +711,12 @@ public R resourcePatch( UnaryOperator updateOperation, ManagedInformerEventSource ies, Options options) { - return ies.eventFilteringUpdateAndCacheResource( - resource, updateOperation, options != null && options.forceEventFiltering()); + if (options != null && options.getMode() == Mode.ONLY_CACHE) { + return ies.updateAndCacheResource(resource, updateOperation); + } else { + return ies.eventFilteringUpdateAndCacheResource( + resource, updateOperation, options != null && options.isForcedFiltering()); + } } /** @@ -827,11 +906,45 @@ public P addFinalizerWithSSA(String finalizerName) { } } - /** - * Force filtering only if it is made sure that the update not results on a no-op change. See - * details here: PR - * 3484 - */ - @Experimental("This API might change") - public record Options(boolean forceEventFiltering) {} + // this is designed with forward compatibility in mynd + @Experimental(API_MIGHT_CHANGE) + public static class Options { + + private static final Options FORCED_FILTERING = new Options(Mode.FORCED_FILTERING); + private static final Options ONLY_CACHE = new Options(Mode.ONLY_CACHE); + private static final Options DEFAULT = new Options(Mode.FILTERING_IF_OPTIMISTIC_LOCKING); + + private final Mode mode; + + public static Options forcedFiltering() { + return FORCED_FILTERING; + } + + public static Options onlyCache() { + return ONLY_CACHE; + } + + public static Options defaultMode() { + return DEFAULT; + } + + private Options(Mode mode) { + this.mode = mode; + } + + public Mode getMode() { + return mode; + } + + public boolean isForcedFiltering() { + return mode != Mode.ONLY_CACHE; + } + } + + @Experimental(API_MIGHT_CHANGE) + public enum Mode { + FILTERING_IF_OPTIMISTIC_LOCKING, + FORCED_FILTERING, + ONLY_CACHE, + } } 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 b69cf933a4..152cb1eb0a 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 @@ -93,7 +93,10 @@ public R create(R desired, P primary, Context

context) { ssa); return ssa ? context.resourceOperations().serverSideApply(desired, eventSource().orElse(null)) - : context.resourceOperations().create(desired, eventSource().orElse(null)); + : context + .resourceOperations() + .create( + desired, eventSource().orElse(null), ResourceOperations.Options.forcedFiltering()); } public R update(R actual, R desired, P primary, Context

context) { @@ -117,7 +120,9 @@ public R update(R actual, R desired, P primary, Context

context) { context .resourceOperations() .serverSideApply( - desired, eventSource().orElse(null), new ResourceOperations.Options(true)); + desired, + eventSource().orElse(null), + ResourceOperations.Options.forcedFiltering()); } else { var updatedActual = GenericResourceUpdater.updateResource(actual, desired, context); updatedResource = diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java index 9739a6d163..31aea1d655 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java @@ -49,6 +49,8 @@ import io.javaoperatorsdk.operator.processing.event.source.*; import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; +import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE; + @SuppressWarnings("rawtypes") public abstract class ManagedInformerEventSource< R extends HasMetadata, P extends HasMetadata, C extends Informable> @@ -89,8 +91,7 @@ public void changeNamespaces(Set namespaces) { } } - @Experimental( - "Internal API. Use ResourceOperation not this directly, this API might change in the future") + @Experimental(API_MIGHT_CHANGE) public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator updateMethod) { return eventFilteringUpdateAndCacheResource(resourceToUpdate, updateMethod, false); } @@ -100,8 +101,7 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator< * Also makes sure that the even produced by this update is filtered, thus does not trigger the * reconciliation. */ - @Experimental( - "Internal API. Use ResourceOperation not this directly, this API might change in the future") + @Experimental(API_MIGHT_CHANGE) @SuppressWarnings("unchecked") public R eventFilteringUpdateAndCacheResource( R resourceToUpdate, UnaryOperator updateMethod, boolean forceUpdateFilter) { @@ -149,7 +149,8 @@ public R eventFilteringUpdateAndCacheResource( } } - private R updateAndCacheResource(R resourceToUpdate, UnaryOperator updateOperation) { + @Experimental(API_MIGHT_CHANGE) + public R updateAndCacheResource(R resourceToUpdate, UnaryOperator updateOperation) { var result = updateOperation.apply(resourceToUpdate); handleRecentResourceUpdate(ResourceID.fromResource(resourceToUpdate), result, resourceToUpdate); return result; diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java index 16cec09eee..9c02059e4c 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java @@ -55,7 +55,7 @@ public UpdateControl reconcile( context .resourceOperations() - .serverSideApply(configMap(primary), new ResourceOperations.Options(true)); + .serverSideApply(configMap(primary), ResourceOperations.Options.forcedFiltering()); if (primary.getStatus() == null) { primary.setStatus(new ChangeNamespaceTestCustomResourceStatus()); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java index d18ed0ef27..c1ec88e4a0 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java @@ -83,7 +83,8 @@ public UpdateControl reconcile( case NO_RELIST -> context .resourceOperations() - .serverSideApply(cm, configMapEventSource, new ResourceOperations.Options(true)); + .serverSideApply( + cm, configMapEventSource, ResourceOperations.Options.forcedFiltering()); case RELIST_AROUND_UPDATE -> { configMapEventSource.simulateOnBeforeList(); var applied = context.resourceOperations().serverSideApply(cm, configMapEventSource); @@ -100,7 +101,8 @@ public UpdateControl reconcile( configMapEventSource.simulateOnList(); context .resourceOperations() - .serverSideApply(cm, configMapEventSource, new ResourceOperations.Options(true)); + .serverSideApply( + cm, configMapEventSource, ResourceOperations.Options.forcedFiltering()); } case RELIST_STARTS_DURING_UPDATE -> { // Drive the event-filtering update path manually so we can fire onBeforeList AFTER the diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java index b65d540ee2..928e66c4d1 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java @@ -54,7 +54,9 @@ public UpdateControl reconcile( context .resourceOperations() .serverSideApply( - prepareCM(resource, i), configMapEventSource, new ResourceOperations.Options(true)); + prepareCM(resource, i), + configMapEventSource, + ResourceOperations.Options.forcedFiltering()); } return UpdateControl.noUpdate(); } From d4b380c37e7bcaf384eb4eba457cc10dbfb96702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sat, 11 Jul 2026 15:32:32 +0200 Subject: [PATCH 12/35] remove un-used class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/api/reconciler/Matcher.java | 23 ------------------- 1 file changed, 23 deletions(-) delete mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Matcher.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Matcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Matcher.java deleted file mode 100644 index b5d0685245..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Matcher.java +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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.api.reconciler; - -import io.fabric8.kubernetes.api.model.HasMetadata; - -public interface Matcher { - - boolean matches(R desired, R actual, Context context); -} From d5c55943fe87f24d51f28d9669ea6f309ff293e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sat, 11 Jul 2026 15:37:53 +0200 Subject: [PATCH 13/35] Potential fix for pull request finding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Attila Mészáros --- .../operator/api/reconciler/ResourceOperations.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 594758ecfa..5f30a3c55f 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -936,9 +936,8 @@ public Mode getMode() { return mode; } - public boolean isForcedFiltering() { - return mode != Mode.ONLY_CACHE; - } + public boolean isForcedFiltering() { + return mode == Mode.FORCED_FILTERING; } @Experimental(API_MIGHT_CHANGE) From 9b179cda70d111b6966d2c988f4ed60675d129fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sun, 12 Jul 2026 22:03:31 +0200 Subject: [PATCH 14/35] fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/api/reconciler/ResourceOperations.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 5f30a3c55f..5c89e3b693 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -936,8 +936,9 @@ public Mode getMode() { return mode; } - public boolean isForcedFiltering() { - return mode == Mode.FORCED_FILTERING; + public boolean isForcedFiltering() { + return mode == Mode.FORCED_FILTERING; + } } @Experimental(API_MIGHT_CHANGE) From 67640d4f0f89596e87e72e18f64976ab6f4e9954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 13 Jul 2026 15:33:39 +0200 Subject: [PATCH 15/35] revworked resource operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/reconciler/ResourceOperations.java | 275 ++++++++++++------ .../matcher/JsonMergePatchMatcher.java | 8 + .../matcher/JsonMergePatchStatusMatcher.java | 8 + .../reconciler/matcher/JsonPatchMacher.java | 8 + .../matcher/JsonPatchStatusMacher.java | 8 + .../api/reconciler/matcher/SSAMatcher.java | 8 + .../reconciler/matcher/SSAStatusMatcher.java | 8 + .../api/reconciler/matcher/UpdateMatcher.java | 8 + .../matcher/UpdateStatusMatcher.java | 8 + .../api/reconciler/matcher/UpdateType.java | 37 +++ .../KubernetesDependentResource.java | 7 +- .../informer/ManagedInformerEventSource.java | 12 +- .../reconciler/ResourceOperationsTest.java | 166 +++++++++-- .../informer/InformerEventSourceTest.java | 22 +- .../ChangeNamespaceTestReconciler.java | 2 +- ...ternalUpdateDuringOwnUpdateReconciler.java | 23 +- .../OnRelistFilterReconciler.java | 8 +- .../OwnSecondaryUpdateReconciler.java | 2 +- 18 files changed, 440 insertions(+), 178 deletions(-) create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateType.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 5c89e3b693..d101da43b1 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -16,6 +16,7 @@ package io.javaoperatorsdk.operator.api.reconciler; import java.lang.reflect.InvocationTargetException; +import java.util.Optional; import java.util.function.Predicate; import java.util.function.UnaryOperator; @@ -27,6 +28,8 @@ import io.fabric8.kubernetes.client.dsl.base.PatchContext; import io.fabric8.kubernetes.client.dsl.base.PatchType; import io.javaoperatorsdk.operator.OperatorException; +import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher; +import io.javaoperatorsdk.operator.api.reconciler.matcher.UpdateType; import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; @@ -69,7 +72,7 @@ public ResourceOperations(Context

context) { * @param resource type */ public R serverSideApply(R resource) { - return serverSideApply(resource, (Options) null); + return serverSideApply(resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.SSA)); } @Experimental(API_MIGHT_CHANGE) @@ -163,11 +166,13 @@ public R serverSideApplyStatus(R resource, Options optio } public R serverSideApplyStatus(R resource) { - return serverSideApplyStatus(resource, null); + return serverSideApplyStatus( + resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.SSA_STATUS)); } public P serverSideApplyPrimary(P resource) { - return serverSideApplyPrimary(resource, Options.defaultMode()); + return serverSideApplyPrimary( + resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.SSA)); } /** @@ -216,7 +221,8 @@ public P serverSideApplyPrimary(P resource, Options options) { * @return updated resource */ public P serverSideApplyPrimaryStatus(P resource) { - return serverSideApplyPrimaryStatus(resource, Options.defaultMode()); + return serverSideApplyPrimaryStatus( + resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.SSA_STATUS)); } public P serverSideApplyPrimaryStatus(P resource, Options options) { @@ -238,7 +244,7 @@ public P serverSideApplyPrimaryStatus(P resource, Options options) { } public R update(R resource) { - return update(resource, Options.defaultMode()); + return update(resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.UPDATE)); } /** @@ -261,7 +267,8 @@ public R update(R resource, Options options) { public R update( R resource, InformerEventSource informerEventSource) { - return update(resource, informerEventSource, Options.defaultMode()); + return update( + resource, informerEventSource, Options.matchAndFilterWithDefaultMatcher(UpdateType.UPDATE)); } /** @@ -303,10 +310,14 @@ public R update( */ public R create(R resource) { // it is safe to do event filtering for create since check if the resource already exists. - return create(resource, Options.forcedFiltering()); + return create(resource, Options.alwaysFilter()); } public R create(R resource, Options options) { + if (options.getMatcher().isPresent()) { + throw new IllegalArgumentException( + "Create operation does not support matcher. There is nothing to match."); + } return resourcePatch(resource, r -> context.getClient().resource(r).create(), options); } @@ -350,7 +361,8 @@ public R create( * @param resource type */ public R updateStatus(R resource) { - return updateStatus(resource, Options.defaultMode()); + return updateStatus( + resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.UPDATE_STATUS)); } public R updateStatus(R resource, Options options) { @@ -372,7 +384,7 @@ public R updateStatus(R resource, Options options) { * @return updated resource */ public P updatePrimary(P resource) { - return updatePrimary(resource, Options.defaultMode()); + return updatePrimary(resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.UPDATE)); } public P updatePrimary(P resource, Options options) { @@ -401,7 +413,8 @@ public P updatePrimaryStatus(P resource) { return resourcePatch( resource, r -> context.getClient().resource(r).updateStatus(), - context.eventSourceRetriever().getControllerEventSource()); + context.eventSourceRetriever().getControllerEventSource(), + Options.matchAndFilterWithDefaultMatcher(UpdateType.UPDATE_STATUS)); } /** @@ -416,31 +429,32 @@ public P updatePrimaryStatus(P resource) { *

You are free to control the optimistic locking by setting the resource version in resource * metadata. * - * @param resource resource to patch - * @param unaryOperator function to modify the resource * @return updated resource * @param resource type */ - public R jsonPatch(R resource, UnaryOperator unaryOperator) { - return jsonPatch(resource, unaryOperator, null); + public R jsonPatch(R actual, R desired) { + return jsonPatch( + actual, desired, Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_PATCH)); } - public R jsonPatch( - R resource, UnaryOperator unaryOperator, Options options) { + public R jsonPatch(R actual, R desired, Options options) { return resourcePatch( - resource, r -> context.getClient().resource(r).edit(unaryOperator), options); + desired, + actual, + r -> context.getClient().resource(actual).edit(res -> desired), + options, + null); } public R jsonPatch( - R resource, - UnaryOperator unaryOperator, - InformerEventSource informerEventSource, - Options options) { + R desired, R actual, InformerEventSource informerEventSource, Options options) { return resourcePatch( - resource, - r -> context.getClient().resource(r).edit(unaryOperator), + desired, + actual, + r -> context.getClient().resource(actual).edit(res -> desired), informerEventSource, - options); + options, + null); } /** @@ -455,29 +469,29 @@ public R jsonPatch( *

You are free to control the optimistic locking by setting the resource version in resource * metadata. * - * @param resource resource to patch - * @param unaryOperator function to modify the resource + * @param desired resource to patch * @return updated resource * @param resource type */ - public R jsonPatchStatus( - R resource, UnaryOperator unaryOperator, Options options) { - return resourcePatch( - resource, r -> context.getClient().resource(r).editStatus(unaryOperator), options); + public R jsonPatchStatus(R desired, R actual) { + return jsonPatchStatus( + desired, actual, Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_PATCH_STATUS)); } - public R jsonPatchStatus(R resource, UnaryOperator unaryOperator) { - return jsonPatchStatus(resource, unaryOperator, Options.defaultMode()); + public R jsonPatchStatus(R desired, R actual, Options options) { + return resourcePatch( + desired, + actual, + r -> context.getClient().resource(actual).editStatus(res -> desired), + options, + null); } public R jsonPatchStatus( - R resource, - UnaryOperator unaryOperator, - InformerEventSource informerEventSource, - Options options) { + R desired, R actual, InformerEventSource informerEventSource, Options options) { return resourcePatch( - resource, - r -> context.getClient().resource(r).editStatus(unaryOperator), + desired, + r -> context.getClient().resource(actual).editStatus(res -> desired), informerEventSource, options); } @@ -498,7 +512,7 @@ public R jsonPatchStatus( * @return updated resource */ public P jsonPatchPrimary(P resource, UnaryOperator

unaryOperator) { - return jsonPatchPrimary(resource, unaryOperator, Options.defaultMode()); + return jsonPatchPrimary(resource, unaryOperator, Options.filterIfOptimisticLocking()); } public P jsonPatchPrimary(P resource, UnaryOperator

unaryOperator, Options options) { @@ -525,7 +539,7 @@ public P jsonPatchPrimary(P resource, UnaryOperator

unaryOperator, Options op * @return updated resource */ public P jsonPatchPrimaryStatus(P resource, UnaryOperator

unaryOperator) { - return jsonPatchPrimaryStatus(resource, unaryOperator, Options.defaultMode()); + return jsonPatchPrimaryStatus(resource, unaryOperator, Options.filterIfOptimisticLocking()); } public P jsonPatchPrimaryStatus(P resource, UnaryOperator

unaryOperator, Options options) { @@ -548,22 +562,23 @@ public P jsonPatchPrimaryStatus(P resource, UnaryOperator

unaryOperator, Opti *

You are free to control the optimistic locking by setting the resource version in resource * metadata. * - * @param resource resource to patch + * @param desired resource to patch * @return updated resource * @param resource type */ - public R jsonMergePatch(R resource) { - return jsonMergePatch(resource, Options.defaultMode()); + public R jsonMergePatch(R desired) { + return jsonMergePatch( + desired, Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_MERGE_PATCH)); } - public R jsonMergePatch(R resource, Options options) { - return resourcePatch(resource, r -> context.getClient().resource(r).patch(), options); + public R jsonMergePatch(R desired, Options options) { + return resourcePatch(desired, r -> context.getClient().resource(r).patch(), options); } public R jsonMergePatch( - R resource, InformerEventSource informerEventSource, Options options) { + R desired, InformerEventSource informerEventSource, Options options) { return resourcePatch( - resource, r -> context.getClient().resource(r).patch(), informerEventSource, options); + desired, r -> context.getClient().resource(r).patch(), informerEventSource, options); } /** @@ -582,7 +597,8 @@ public R jsonMergePatch( * @param resource type */ public R jsonMergePatchStatus(R resource) { - return jsonMergePatchStatus(resource, null); + return jsonMergePatchStatus( + resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_MERGE_PATCH_STATUS)); } public R jsonMergePatchStatus(R resource, Options options) { @@ -611,7 +627,8 @@ public R jsonMergePatchStatus( * @return updated resource */ public P jsonMergePatchPrimary(P resource) { - return jsonMergePatchPrimary(resource, Options.defaultMode()); + return jsonMergePatchPrimary( + resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_MERGE_PATCH)); } public P jsonMergePatchPrimary(P resource, Options options) { @@ -638,7 +655,8 @@ public P jsonMergePatchPrimary(P resource, Options options) { * @see #jsonMergePatchPrimaryStatus(HasMetadata) */ public P jsonMergePatchPrimaryStatus(P resource) { - return jsonMergePatchPrimaryStatus(resource, Options.defaultMode()); + return jsonMergePatchPrimaryStatus( + resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_MERGE_PATCH_STATUS)); } public P jsonMergePatchPrimaryStatus(P resource, Options options) { @@ -650,15 +668,11 @@ public P jsonMergePatchPrimaryStatus(P resource, Options options) { } public R resourcePatch(R resource, UnaryOperator updateOperation) { - return resourcePatch(resource, updateOperation, Options.defaultMode()); + return resourcePatch(resource, updateOperation, Options.filterIfOptimisticLocking()); } /** - * Utility method to patch a resource and cache the result. Automatically discovers the event - * source for the resource type and delegates to {@link #resourcePatch(HasMetadata, UnaryOperator, - * ManagedInformerEventSource)}. - * - * @param resource resource to patch + * @param desired resource to patch * @param updateOperation operation to perform (update, patch, edit, etc.) * @return updated resource * @param resource type @@ -666,23 +680,42 @@ public R resourcePatch(R resource, UnaryOperator upda */ @Experimental(API_MIGHT_CHANGE) @SuppressWarnings({"rawtypes", "unchecked"}) - public R resourcePatch( - R resource, UnaryOperator updateOperation, Options options) { + private R resourcePatch( + R desired, UnaryOperator updateOperation, Options options) { + return resourcePatch(desired, updateOperation, options, null); + } - var esList = context.eventSourceRetriever().getEventSourcesFor(resource.getClass()); + @Experimental(API_MIGHT_CHANGE) + @SuppressWarnings({"rawtypes", "unchecked"}) + private R resourcePatch( + R desired, UnaryOperator updateOperation, Options options, Matcher matcher) { + return resourcePatch(desired, null, updateOperation, options, matcher); + } + + @Experimental(API_MIGHT_CHANGE) + @SuppressWarnings({"rawtypes", "unchecked"}) + private R resourcePatch( + R desired, R actual, UnaryOperator updateOperation, Options options, Matcher matcher) { + + var esList = context.eventSourceRetriever().getEventSourcesFor(desired.getClass()); if (esList.isEmpty()) { - throw new IllegalStateException("No event source found for type: " + resource.getClass()); + throw new IllegalStateException("No event source found for type: " + desired.getClass()); } var es = esList.get(0); if (esList.size() > 1) { log.warn( "Multiple event sources found for type: {}, selecting first with name {}", - resource.getClass(), + desired.getClass(), es.name()); } if (es instanceof ManagedInformerEventSource mes) { return resourcePatch( - resource, updateOperation, (ManagedInformerEventSource) mes, options); + desired, + actual, + updateOperation, + (ManagedInformerEventSource) mes, + options, + matcher); } else { throw new IllegalStateException( "Target event source must be a subclass off " @@ -690,32 +723,52 @@ public R resourcePatch( } } - /** - * Utility method to patch a resource and cache the result using the specified event source. This - * method either filters out the resulting event or allows it to trigger reconciliation based on - * the filterEvent parameter. - * - * @param resource resource to patch - * @param updateOperation operation to perform (update, patch, edit, etc.) - * @param ies the managed informer event source to use for caching - * @return updated resource - * @param resource type - */ public R resourcePatch( - R resource, UnaryOperator updateOperation, ManagedInformerEventSource ies) { - return resourcePatch(resource, updateOperation, ies, (Options) null); + R desired, UnaryOperator updateOperation, ManagedInformerEventSource ies) { + return resourcePatch(desired, updateOperation, Options.filterIfOptimisticLocking()); } public R resourcePatch( - R resource, + R desired, UnaryOperator updateOperation, ManagedInformerEventSource ies, Options options) { - if (options != null && options.getMode() == Mode.ONLY_CACHE) { - return ies.updateAndCacheResource(resource, updateOperation); + R originalResource = null; + if (options.getMatcher().isPresent()) { + originalResource = ies.get(ResourceID.fromResource(desired)).orElse(null); + } + return resourcePatch(desired, originalResource, updateOperation, ies, options, null); + } + + public R resourcePatch( + R desiredResource, + R actualResource, + UnaryOperator updateOperation, + ManagedInformerEventSource ies, + Options options, + Matcher matcherToUse) { + + var matcher = matcherToUse == null ? options.getMatcher().orElse(null) : matcherToUse; + boolean matches = false; + if (matcher != null) { + if (actualResource == null) { + throw new IllegalArgumentException("Actual most be present for matching update/patch."); + } + matches = matcher.matches(desiredResource, actualResource, context); + } + if (options.requiresMatcher() && matcher == null) { + throw new IllegalArgumentException("Mode : " + options.mode + " requires matcher"); + } + if (matches) { + return actualResource; + } + boolean optimisticLocking = desiredResource.getMetadata().getResourceVersion() != null; + + if (options.getMode() == Mode.ONLY_CACHE + || (options.getMode() == Mode.FILTER_IF_OPTIMISTIC_LOCKING && !optimisticLocking)) { + return ies.updateAndCacheResource(desiredResource, updateOperation); } else { - return ies.eventFilteringUpdateAndCacheResource( - resource, updateOperation, options != null && options.isForcedFiltering()); + return ies.eventFilteringUpdateAndCacheResource(desiredResource, updateOperation); } } @@ -893,7 +946,7 @@ public P addFinalizerWithSSA(String finalizerName) { resource.initNameAndNamespaceFrom(originalResource); resource.addFinalizer(finalizerName); - return serverSideApplyPrimary(resource); + return serverSideApplyPrimary(resource, Options.onlyCache()); } catch (InstantiationException | IllegalAccessException | InvocationTargetException @@ -906,45 +959,77 @@ public P addFinalizerWithSSA(String finalizerName) { } } - // this is designed with forward compatibility in mynd @Experimental(API_MIGHT_CHANGE) public static class Options { - private static final Options FORCED_FILTERING = new Options(Mode.FORCED_FILTERING); - private static final Options ONLY_CACHE = new Options(Mode.ONLY_CACHE); - private static final Options DEFAULT = new Options(Mode.FILTERING_IF_OPTIMISTIC_LOCKING); + private static final Options ALWAYS_FILTER = new Options(Mode.ALWAYS_FILTER, null); + private static final Options ONLY_CACHE = new Options(Mode.ONLY_CACHE, null); + private static final Options FILTER_IF_OPTIMISTIC_LOCKING = + new Options(Mode.FILTER_IF_OPTIMISTIC_LOCKING, null); private final Mode mode; + private Matcher matcher; + + private Options(Mode mode, Matcher matcher) { + this.mode = mode; + this.matcher = matcher; + } - public static Options forcedFiltering() { - return FORCED_FILTERING; + public static Options alwaysFilter() { + return ALWAYS_FILTER; } public static Options onlyCache() { return ONLY_CACHE; } - public static Options defaultMode() { - return DEFAULT; + public static Options onlyCache(Matcher matcher) { + return new Options(Mode.ONLY_CACHE, matcher); } - private Options(Mode mode) { - this.mode = mode; + public static Options filterIfOptimisticLocking() { + return FILTER_IF_OPTIMISTIC_LOCKING; + } + + /** + * Filters own updates and, before doing so, checks whether the actual resource already matches + * the desired state using the given {@link Matcher}. + * + * @param matcher the matcher used to decide whether actual already matches desired + */ + public static Options matchAndFilter(Matcher matcher) { + if (matcher == null) { + throw new IllegalArgumentException("Matcher cannot be null for matchAndFilterOperation"); + } + return new Options(Mode.FILTER_IF_NOT_MATCHING, matcher); + } + + public static Options matchAndFilterWithDefaultMatcher(UpdateType updateType) { + return new Options(Mode.FILTER_IF_NOT_MATCHING, updateType.getMatcher()); } public Mode getMode() { return mode; } - public boolean isForcedFiltering() { - return mode == Mode.FORCED_FILTERING; + public Optional getMatcher() { + return Optional.ofNullable(matcher); + } + + public boolean isAlwaysFilter() { + return mode == Mode.ALWAYS_FILTER; + } + + public boolean requiresMatcher() { + return mode == Mode.FILTER_IF_NOT_MATCHING; } } @Experimental(API_MIGHT_CHANGE) public enum Mode { - FILTERING_IF_OPTIMISTIC_LOCKING, - FORCED_FILTERING, + FILTER_IF_OPTIMISTIC_LOCKING, + FILTER_IF_NOT_MATCHING, ONLY_CACHE, + ALWAYS_FILTER, } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchMatcher.java index 25e290c87f..22a43476d6 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchMatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchMatcher.java @@ -20,6 +20,14 @@ public class JsonMergePatchMatcher implements Matcher { + private static final JsonMergePatchMatcher INSTANCE = new JsonMergePatchMatcher(); + + public static JsonMergePatchMatcher getInstance() { + return INSTANCE; + } + + private JsonMergePatchMatcher() {} + @Override public boolean matches(HasMetadata desired, HasMetadata actual, Context context) { return MatcherUtils.mergePatchMatches( diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchStatusMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchStatusMatcher.java index 4c29948022..9840f57077 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchStatusMatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchStatusMatcher.java @@ -20,6 +20,14 @@ public class JsonMergePatchStatusMatcher implements Matcher { + private static final JsonMergePatchStatusMatcher INSTANCE = new JsonMergePatchStatusMatcher(); + + public static JsonMergePatchStatusMatcher getInstance() { + return INSTANCE; + } + + private JsonMergePatchStatusMatcher() {} + @Override public boolean matches(HasMetadata desired, HasMetadata actual, Context context) { return MatcherUtils.mergePatchMatches( diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchMacher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchMacher.java index c89dc92a97..7f9f30c442 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchMacher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchMacher.java @@ -20,6 +20,14 @@ public class JsonPatchMacher implements Matcher { + private static final JsonPatchMacher INSTANCE = new JsonPatchMacher(); + + public static JsonPatchMacher getInstance() { + return INSTANCE; + } + + private JsonPatchMacher() {} + @Override public boolean matches(HasMetadata desired, HasMetadata actual, Context context) { return MatcherUtils.jsonPatchMatches( diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchStatusMacher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchStatusMacher.java index a48668c535..d55d0a6413 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchStatusMacher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchStatusMacher.java @@ -20,6 +20,14 @@ public class JsonPatchStatusMacher implements Matcher { + private static final JsonPatchStatusMacher INSTANCE = new JsonPatchStatusMacher(); + + public static JsonPatchStatusMacher getInstance() { + return INSTANCE; + } + + private JsonPatchStatusMacher() {} + @Override public boolean matches(HasMetadata desired, HasMetadata actual, Context context) { return MatcherUtils.jsonPatchMatches( diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAMatcher.java index f082b2841e..540861a3b0 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAMatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAMatcher.java @@ -21,6 +21,14 @@ public class SSAMatcher implements Matcher { + private static final SSAMatcher INSTANCE = new SSAMatcher(); + + public static SSAMatcher getInstance() { + return INSTANCE; + } + + private SSAMatcher() {} + @Override public boolean matches(HasMetadata desired, HasMetadata actual, Context context) { return SSABasedGenericKubernetesResourceMatcher.getInstance().matches(actual, desired, context); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAStatusMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAStatusMatcher.java index d56ac815ec..edf4a7d25d 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAStatusMatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAStatusMatcher.java @@ -21,6 +21,14 @@ public class SSAStatusMatcher implements Matcher { + private static final SSAStatusMatcher INSTANCE = new SSAStatusMatcher(); + + public static SSAStatusMatcher getInstance() { + return INSTANCE; + } + + private SSAStatusMatcher() {} + @Override public boolean matches(HasMetadata desired, HasMetadata actual, Context context) { return GenericKubernetesResourceMatcher.matchStatus(desired, actual, context).matched(); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateMatcher.java index e270fe7812..63f3f7b35e 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateMatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateMatcher.java @@ -21,6 +21,14 @@ public class UpdateMatcher implements Matcher { + private static final UpdateMatcher INSTANCE = new UpdateMatcher(); + + public static UpdateMatcher getInstance() { + return INSTANCE; + } + + private UpdateMatcher() {} + @Override public boolean matches(HasMetadata desired, HasMetadata actual, Context context) { return GenericKubernetesResourceMatcher.match(desired, actual, context).matched(); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateStatusMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateStatusMatcher.java index e9c99acbd3..b2d646367c 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateStatusMatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateStatusMatcher.java @@ -21,6 +21,14 @@ public class UpdateStatusMatcher implements Matcher { + private static final UpdateStatusMatcher INSTANCE = new UpdateStatusMatcher(); + + public static UpdateStatusMatcher getInstance() { + return INSTANCE; + } + + private UpdateStatusMatcher() {} + @Override public boolean matches(HasMetadata desired, HasMetadata actual, Context context) { return GenericKubernetesResourceMatcher.matchStatus(desired, actual, context).matched(); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateType.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateType.java new file mode 100644 index 0000000000..05ba0e7ecc --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateType.java @@ -0,0 +1,37 @@ +/* + * 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.api.reconciler.matcher; + +public enum UpdateType { + UPDATE(UpdateMatcher.getInstance()), + UPDATE_STATUS(UpdateStatusMatcher.getInstance()), + SSA(SSAMatcher.getInstance()), + SSA_STATUS(SSAStatusMatcher.getInstance()), + JSON_PATCH(JsonPatchMacher.getInstance()), + JSON_PATCH_STATUS(JsonPatchStatusMacher.getInstance()), + JSON_MERGE_PATCH(JsonMergePatchMatcher.getInstance()), + JSON_MERGE_PATCH_STATUS(JsonMergePatchStatusMatcher.getInstance()); + + private final Matcher defaultMatcher; + + UpdateType(Matcher defaultMatcher) { + this.defaultMatcher = defaultMatcher; + } + + public Matcher getMatcher() { + return defaultMatcher; + } +} 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 152cb1eb0a..be3ea501a0 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 @@ -95,8 +95,7 @@ public R create(R desired, P primary, Context

context) { ? context.resourceOperations().serverSideApply(desired, eventSource().orElse(null)) : context .resourceOperations() - .create( - desired, eventSource().orElse(null), ResourceOperations.Options.forcedFiltering()); + .create(desired, eventSource().orElse(null), ResourceOperations.Options.alwaysFilter()); } public R update(R actual, R desired, P primary, Context

context) { @@ -120,9 +119,7 @@ public R update(R actual, R desired, P primary, Context

context) { context .resourceOperations() .serverSideApply( - desired, - eventSource().orElse(null), - ResourceOperations.Options.forcedFiltering()); + desired, eventSource().orElse(null), ResourceOperations.Options.alwaysFilter()); } else { var updatedActual = GenericResourceUpdater.updateResource(actual, desired, context); updatedResource = diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java index 31aea1d655..4a29a42144 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java @@ -91,11 +91,6 @@ public void changeNamespaces(Set namespaces) { } } - @Experimental(API_MIGHT_CHANGE) - public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator updateMethod) { - return eventFilteringUpdateAndCacheResource(resourceToUpdate, updateMethod, false); - } - /** * Updates the resource and makes sure that the response is available for the next reconciliation. * Also makes sure that the even produced by this update is filtered, thus does not trigger the @@ -103,12 +98,7 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator< */ @Experimental(API_MIGHT_CHANGE) @SuppressWarnings("unchecked") - public R eventFilteringUpdateAndCacheResource( - R resourceToUpdate, UnaryOperator updateMethod, boolean forceUpdateFilter) { - if (resourceToUpdate.getMetadata().getResourceVersion() == null && !forceUpdateFilter) { - log.debug("No resourceVersion set. Skipping event filtering."); - return updateAndCacheResource(resourceToUpdate, updateMethod); - } + public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator updateMethod) { ResourceID id = ResourceID.fromResource(resourceToUpdate); log.debug("Starting event filtering and caching update for id={}", id); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java index abc4326dd1..7870fa4d92 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java @@ -28,6 +28,7 @@ import io.fabric8.kubernetes.client.dsl.Resource; import io.javaoperatorsdk.operator.TestUtils; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher; import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever; import io.javaoperatorsdk.operator.processing.event.source.EventSource; import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource; @@ -84,7 +85,7 @@ void addsFinalizer() { // Mock successful finalizer addition when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class), anyBoolean())) + any(), any(UnaryOperator.class))) .thenAnswer( invocation -> { var res = TestUtils.testCustomResource1(); @@ -99,7 +100,7 @@ void addsFinalizer() { assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); verify(controllerEventSource, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); } @Test @@ -110,8 +111,7 @@ void addsFinalizerWithSSA() { when(context.getPrimaryResource()).thenReturn(resource); // Mock successful SSA finalizer addition - when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class), anyBoolean())) + when(controllerEventSource.updateAndCacheResource(any(), any(UnaryOperator.class))) .thenAnswer( invocation -> { var res = TestUtils.testCustomResource1(); @@ -125,8 +125,7 @@ void addsFinalizerWithSSA() { assertThat(result).isNotNull(); assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); - verify(controllerEventSource, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); + verify(controllerEventSource, times(1)).updateAndCacheResource(any(), any(UnaryOperator.class)); } @Test @@ -139,7 +138,7 @@ void removesFinalizer() { // Mock successful finalizer removal when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class), anyBoolean())) + any(), any(UnaryOperator.class))) .thenAnswer( invocation -> { var res = TestUtils.testCustomResource1(); @@ -154,7 +153,7 @@ void removesFinalizer() { assertThat(result.hasFinalizer(FINALIZER_NAME)).isFalse(); assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); verify(controllerEventSource, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); } @Test @@ -166,7 +165,7 @@ void retriesAddingFinalizerWithoutSSA() { // First call throws conflict, second succeeds when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class), anyBoolean())) + any(), any(UnaryOperator.class))) .thenThrow(new KubernetesClientException("Conflict", 409, null)) .thenAnswer( invocation -> { @@ -184,7 +183,7 @@ void retriesAddingFinalizerWithoutSSA() { assertThat(result).isNotNull(); assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); verify(controllerEventSource, times(2)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); verify(resourceOp, times(1)).get(); } @@ -198,7 +197,7 @@ void nullResourceIsGracefullyHandledOnFinalizerRemovalRetry() { // First call throws conflict when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class), anyBoolean())) + any(), any(UnaryOperator.class))) .thenThrow(new KubernetesClientException("Conflict", 409, null)); // Return null on retry (resource was deleted) @@ -207,7 +206,7 @@ void nullResourceIsGracefullyHandledOnFinalizerRemovalRetry() { resourceOperations.removeFinalizer(FINALIZER_NAME); verify(controllerEventSource, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); verify(resourceOp, times(1)).get(); } @@ -221,7 +220,7 @@ void retriesFinalizerRemovalWithFreshResource() { // First call throws unprocessable (422), second succeeds when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class), anyBoolean())) + any(), any(UnaryOperator.class))) .thenThrow(new KubernetesClientException("Unprocessable", 422, null)) .thenAnswer( invocation -> { @@ -243,7 +242,7 @@ void retriesFinalizerRemovalWithFreshResource() { assertThat(result.getMetadata().getResourceVersion()).isEqualTo("3"); assertThat(result.hasFinalizer(FINALIZER_NAME)).isFalse(); verify(controllerEventSource, times(2)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); verify(resourceOp, times(1)).get(); } @@ -261,8 +260,7 @@ void resourcePatchWithSingleEventSource() { when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) .thenReturn(List.of(managedEventSource)); - when(managedEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class), anyBoolean())) + when(managedEventSource.eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class))) .thenReturn(updatedResource); var result = resourceOperations.resourcePatch(resource, UnaryOperator.identity()); @@ -270,7 +268,7 @@ void resourcePatchWithSingleEventSource() { assertThat(result).isNotNull(); assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); verify(managedEventSource, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); } @Test @@ -304,7 +302,7 @@ void resourcePatchUsesFirstEventSourceIfMultipleEventSourcesPresent() { resourceOperations.resourcePatch(resource, UnaryOperator.identity()); verify(eventSource1, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); } @Test @@ -325,4 +323,136 @@ void resourcePatchThrowsWhenEventSourceIsNotManagedInformer() { assertThat(exception.getMessage()).contains("Target event source must be a subclass off"); assertThat(exception.getMessage()).contains("ManagedInformerEventSource"); } + + @Test + void matcherMatchingSkipsUpdateAndReturnsActual() { + var desired = TestUtils.testCustomResource1(); + var actual = TestUtils.testCustomResource1(); + var ies = mock(ManagedInformerEventSource.class); + var matcher = mock(Matcher.class); + when(matcher.matches(desired, actual, context)).thenReturn(true); + + var result = + resourceOperations.resourcePatch( + desired, + actual, + UnaryOperator.identity(), + ies, + ResourceOperations.Options.alwaysFilter(), + matcher); + + assertThat(result).isSameAs(actual); + verify(ies, never()).eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + verify(ies, never()).updateAndCacheResource(any(), any(UnaryOperator.class)); + } + + @Test + void matcherNotMatchingProceedsToEventFilteringUpdate() { + var desired = TestUtils.testCustomResource1(); + var actual = TestUtils.testCustomResource1(); + var updated = TestUtils.testCustomResource1(); + var ies = mock(ManagedInformerEventSource.class); + var matcher = mock(Matcher.class); + when(matcher.matches(desired, actual, context)).thenReturn(false); + when(ies.eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class))) + .thenReturn(updated); + + var result = + resourceOperations.resourcePatch( + desired, + actual, + UnaryOperator.identity(), + ies, + ResourceOperations.Options.alwaysFilter(), + matcher); + + assertThat(result).isSameAs(updated); + verify(ies, times(1)) + .eventFilteringUpdateAndCacheResource(eq(desired), any(UnaryOperator.class)); + } + + @Test + void matcherRequiresActualToBePresent() { + var desired = TestUtils.testCustomResource1(); + var ies = mock(ManagedInformerEventSource.class); + var matcher = mock(Matcher.class); + + assertThrows( + IllegalArgumentException.class, + () -> + resourceOperations.resourcePatch( + desired, + null, + UnaryOperator.identity(), + ies, + ResourceOperations.Options.alwaysFilter(), + matcher)); + } + + @Test + void onlyCacheModeSkipsEventFiltering() { + var desired = TestUtils.testCustomResource1(); + var ies = mock(ManagedInformerEventSource.class); + when(ies.updateAndCacheResource(any(), any(UnaryOperator.class))).thenReturn(desired); + + resourceOperations.resourcePatch( + desired, null, UnaryOperator.identity(), ies, ResourceOperations.Options.onlyCache(), null); + + verify(ies, times(1)).updateAndCacheResource(eq(desired), any(UnaryOperator.class)); + verify(ies, never()).eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + } + + @Test + void filterIfOptimisticLockingCachesWithoutFilteringWhenNoResourceVersion() { + var desired = TestUtils.testCustomResource1(); + desired.getMetadata().setResourceVersion(null); + var ies = mock(ManagedInformerEventSource.class); + when(ies.updateAndCacheResource(any(), any(UnaryOperator.class))).thenReturn(desired); + + resourceOperations.resourcePatch( + desired, + null, + UnaryOperator.identity(), + ies, + ResourceOperations.Options.filterIfOptimisticLocking(), + null); + + verify(ies, times(1)).updateAndCacheResource(eq(desired), any(UnaryOperator.class)); + verify(ies, never()).eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + } + + @Test + void filterIfOptimisticLockingFiltersWhenResourceVersionPresent() { + var desired = TestUtils.testCustomResource1(); + desired.getMetadata().setResourceVersion("1"); + var ies = mock(ManagedInformerEventSource.class); + when(ies.eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class))) + .thenReturn(desired); + + resourceOperations.resourcePatch( + desired, + null, + UnaryOperator.identity(), + ies, + ResourceOperations.Options.filterIfOptimisticLocking(), + null); + + verify(ies, times(1)) + .eventFilteringUpdateAndCacheResource(eq(desired), any(UnaryOperator.class)); + verify(ies, never()).updateAndCacheResource(any(), any(UnaryOperator.class)); + } + + @Test + void createRejectsMatcher() { + var resource = TestUtils.testCustomResource1(); + var matcher = mock(Matcher.class); + + var exception = + assertThrows( + IllegalArgumentException.class, + () -> + resourceOperations.create(resource, ResourceOperations.Options.onlyCache(matcher))); + + assertThat(exception.getMessage()).contains("does not support matcher"); + } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java index 31cbc26d11..210ce52fcc 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java @@ -780,25 +780,6 @@ void filteringUpdateFallsBackToMapperWhenNoPrimaryToSecondaryIndex() { verify(eventHandlerMock, times(1)).handleEvent(any()); } - @Test - void skipsEventFilteringWhenResourceVersionIsNull() { - // Without a resourceVersion there is nothing to correlate own-write echoes against, so - // event filtering is bypassed: the update is applied and cached directly, no filter window - // is opened and no event is propagated through handleEvent. - var resourceToUpdate = testDeployment(); - resourceToUpdate.getMetadata().setResourceVersion(null); - var updated = deploymentWithResourceVersion(3); - - var result = - informerEventSource.eventFilteringUpdateAndCacheResource(resourceToUpdate, r -> updated); - - assertThat(result).isSameAs(updated); - verify(temporaryResourceCache, times(1)).putResource(updated); - verify(temporaryResourceCache, never()).startEventFilteringModify(any()); - verify(temporaryResourceCache, never()).doneEventFilterModify(any()); - verify(eventHandlerMock, never()).handleEvent(any()); - } - @Test void forceUpdateFilterOpensFilterWindowEvenWhenResourceVersionIsNull() { // A write without a resourceVersion (e.g. an SSA finalizer add, which uses no optimistic @@ -811,8 +792,7 @@ void forceUpdateFilterOpensFilterWindowEvenWhenResourceVersionIsNull() { when(temporaryResourceCache.doneEventFilterModify(any())).thenReturn(Optional.empty()); var result = - informerEventSource.eventFilteringUpdateAndCacheResource( - resourceToUpdate, r -> updated, true); + informerEventSource.eventFilteringUpdateAndCacheResource(resourceToUpdate, r -> updated); assertThat(result).isSameAs(updated); verify(temporaryResourceCache, times(1)).startEventFilteringModify(any()); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java index 9c02059e4c..2cbda9a7fe 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java @@ -55,7 +55,7 @@ public UpdateControl reconcile( context .resourceOperations() - .serverSideApply(configMap(primary), ResourceOperations.Options.forcedFiltering()); + .serverSideApply(configMap(primary), ResourceOperations.Options.alwaysFilter()); if (primary.getStatus() == null) { primary.setStatus(new ChangeNamespaceTestCustomResourceStatus()); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateReconciler.java index e5c956dc4e..2352a3f94b 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateReconciler.java @@ -16,7 +16,6 @@ package io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.externalupdateduringownupdate; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -47,28 +46,10 @@ public UpdateControl reconcile( if (execution == 1) { var status = new ExternalUpdateDuringOwnUpdateStatus().setValue(STATUS_VALUE); resource.setStatus(status); - // wrap our own status update in resourcePatch with a hook that lets the test // perform an external metadata update WHILE our filter window is still open. - context - .resourceOperations() - .resourcePatch( - resource, - r -> { - updateStartedLatch.countDown(); - try { - if (!externalUpdateDoneLatch.await(30, TimeUnit.SECONDS)) { - throw new RuntimeException("timed out waiting for external update"); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException(e); - } - // server-side state moved due to the external label change; drop our stale rv - r.getMetadata().setResourceVersion(null); - return context.getClient().resource(r).patchStatus(); - }, - context.eventSourceRetriever().getControllerEventSource()); + resource.getMetadata().setResourceVersion(null); + context.resourceOperations().jsonMergePatchPrimary(resource); } else { var labels = resource.getMetadata().getLabels(); if (labels != null && EXTERNAL_LABEL_VALUE.equals(labels.get(EXTERNAL_LABEL_KEY))) { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java index c1ec88e4a0..3c4769bbcc 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java @@ -84,7 +84,7 @@ public UpdateControl reconcile( context .resourceOperations() .serverSideApply( - cm, configMapEventSource, ResourceOperations.Options.forcedFiltering()); + cm, configMapEventSource, ResourceOperations.Options.alwaysFilter()); case RELIST_AROUND_UPDATE -> { configMapEventSource.simulateOnBeforeList(); var applied = context.resourceOperations().serverSideApply(cm, configMapEventSource); @@ -101,8 +101,7 @@ public UpdateControl reconcile( configMapEventSource.simulateOnList(); context .resourceOperations() - .serverSideApply( - cm, configMapEventSource, ResourceOperations.Options.forcedFiltering()); + .serverSideApply(cm, configMapEventSource, ResourceOperations.Options.alwaysFilter()); } case RELIST_STARTS_DURING_UPDATE -> { // Drive the event-filtering update path manually so we can fire onBeforeList AFTER the @@ -122,8 +121,7 @@ public UpdateControl reconcile( .withFieldManager(fieldManager) .withPatchType(PatchType.SERVER_SIDE_APPLY) .build()); - }, - true); + }); // See RELIST_AROUND_UPDATE: wait for the own-write event to be buffered while the // re-list is still in progress, so it is tagged as part of the re-list and propagated. configMapEventSource.awaitWatchEventReceived(applied); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java index 928e66c4d1..265008304c 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java @@ -56,7 +56,7 @@ public UpdateControl reconcile( .serverSideApply( prepareCM(resource, i), configMapEventSource, - ResourceOperations.Options.forcedFiltering()); + ResourceOperations.Options.alwaysFilter()); } return UpdateControl.noUpdate(); } From a8eb193067f29a0e184ca9d5e5c544af2f2d1ca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 13 Jul 2026 22:49:43 +0200 Subject: [PATCH 16/35] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/reconciler/ResourceOperations.java | 199 ++++++++++-------- .../KubernetesDependentResource.java | 9 +- .../event/ReconciliationDispatcher.java | 20 +- .../reconciler/ResourceOperationsTest.java | 34 +-- .../ChangeNamespaceTestReconciler.java | 2 +- .../OnRelistFilterReconciler.java | 5 +- .../OwnSecondaryUpdateReconciler.java | 2 +- 7 files changed, 160 insertions(+), 111 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index d101da43b1..065f645e35 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -217,17 +217,17 @@ public P serverSideApplyPrimary(P resource, Options options) { *

You are free to control the optimistic locking by setting the resource version in resource * metadata. In case of SSA we advise not to do updates with optimistic locking. * - * @param resource primary resource for server side apply + * @param desired primary resource for server side apply * @return updated resource */ - public P serverSideApplyPrimaryStatus(P resource) { + public P serverSideApplyPrimaryStatus(P desired) { return serverSideApplyPrimaryStatus( - resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.SSA_STATUS)); + desired, Options.matchAndFilterWithDefaultMatcher(UpdateType.SSA_STATUS)); } - public P serverSideApplyPrimaryStatus(P resource, Options options) { + public P serverSideApplyPrimaryStatus(P desired, Options options) { return resourcePatch( - resource, + desired, r -> context .getClient() @@ -256,13 +256,13 @@ public R update(R resource) { *

You are free to control the optimistic locking by setting the resource version in resource * metadata. * - * @param resource resource to update + * @param desired resource to update * @return updated resource * @param resource type */ @Experimental(API_MIGHT_CHANGE) - public R update(R resource, Options options) { - return resourcePatch(resource, r -> context.getClient().resource(r).update(), options); + public R update(R desired, Options options) { + return resourcePatch(desired, r -> context.getClient().resource(r).update(), options); } public R update( @@ -310,7 +310,7 @@ public R update( */ public R create(R resource) { // it is safe to do event filtering for create since check if the resource already exists. - return create(resource, Options.alwaysFilter()); + return create(resource, Options.forceFilterEvents()); } public R create(R resource, Options options) { @@ -380,16 +380,16 @@ public R updateStatus(R resource, Options options) { *

You are free to control the optimistic locking by setting the resource version in resource * metadata. * - * @param resource primary resource to update + * @param desired primary resource to update * @return updated resource */ - public P updatePrimary(P resource) { - return updatePrimary(resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.UPDATE)); + public P updatePrimary(P desired) { + return updatePrimary(desired, Options.matchAndFilterWithDefaultMatcher(UpdateType.UPDATE)); } - public P updatePrimary(P resource, Options options) { + public P updatePrimary(P desired, Options options) { return resourcePatch( - resource, + desired, r -> context.getClient().resource(r).update(), context.eventSourceRetriever().getControllerEventSource(), options); @@ -406,12 +406,12 @@ public P updatePrimary(P resource, Options options) { *

You are free to control the optimistic locking by setting the resource version in resource * metadata. * - * @param resource primary resource to update + * @param desired primary resource to update * @return updated resource */ - public P updatePrimaryStatus(P resource) { + public P updatePrimaryStatus(P desired) { return resourcePatch( - resource, + desired, r -> context.getClient().resource(r).updateStatus(), context.eventSourceRetriever().getControllerEventSource(), Options.matchAndFilterWithDefaultMatcher(UpdateType.UPDATE_STATUS)); @@ -432,29 +432,32 @@ public P updatePrimaryStatus(P resource) { * @return updated resource * @param resource type */ - public R jsonPatch(R actual, R desired) { + public R jsonPatch(R actualResource, UnaryOperator unaryOperator) { return jsonPatch( - actual, desired, Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_PATCH)); + actualResource, + unaryOperator, + Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_PATCH)); } - public R jsonPatch(R actual, R desired, Options options) { + public R jsonPatch( + R actualResource, UnaryOperator unaryOperator, Options options) { return resourcePatch( - desired, - actual, - r -> context.getClient().resource(actual).edit(res -> desired), - options, - null); + actualResource, + r -> context.getClient().resource(actualResource).edit(unaryOperator), + options); } public R jsonPatch( - R desired, R actual, InformerEventSource informerEventSource, Options options) { + R actualResource, + UnaryOperator unaryOperator, + InformerEventSource informerEventSource, + Options options) { + return resourcePatch( - desired, - actual, - r -> context.getClient().resource(actual).edit(res -> desired), + actualResource, + r -> context.getClient().resource(actualResource).edit(unaryOperator), informerEventSource, - options, - null); + options); } /** @@ -469,29 +472,39 @@ public R jsonPatch( *

You are free to control the optimistic locking by setting the resource version in resource * metadata. * - * @param desired resource to patch + * @param actualResource resource to patch + * @param unaryOperator function to modify the resource status * @return updated resource * @param resource type */ - public R jsonPatchStatus(R desired, R actual) { + public R jsonPatchStatus( + R actualResource, UnaryOperator unaryOperator) { return jsonPatchStatus( - desired, actual, Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_PATCH_STATUS)); + actualResource, + unaryOperator, + Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_PATCH_STATUS)); } - public R jsonPatchStatus(R desired, R actual, Options options) { + public R jsonPatchStatus( + R actualResource, UnaryOperator unaryOperator, Options options) { + R desired = desiredForJsonPatch(actualResource, unaryOperator, options); return resourcePatch( desired, - actual, - r -> context.getClient().resource(actual).editStatus(res -> desired), - options, - null); + actualResource, + r -> context.getClient().resource(r).editStatus(unaryOperator), + options); } public R jsonPatchStatus( - R desired, R actual, InformerEventSource informerEventSource, Options options) { + R actualResource, + UnaryOperator unaryOperator, + InformerEventSource informerEventSource, + Options options) { + R desired = desiredForJsonPatch(actualResource, unaryOperator, options); return resourcePatch( desired, - r -> context.getClient().resource(actual).editStatus(res -> desired), + actualResource, + r -> context.getClient().resource(r).editStatus(unaryOperator), informerEventSource, options); } @@ -507,18 +520,23 @@ public R jsonPatchStatus( *

You are free to control the optimistic locking by setting the resource version in resource * metadata. * - * @param resource primary resource to patch + * @param actualResource primary resource to patch * @param unaryOperator function to modify the resource * @return updated resource */ - public P jsonPatchPrimary(P resource, UnaryOperator

unaryOperator) { - return jsonPatchPrimary(resource, unaryOperator, Options.filterIfOptimisticLocking()); + public P jsonPatchPrimary(P actualResource, UnaryOperator

unaryOperator) { + return jsonPatchPrimary( + actualResource, + unaryOperator, + Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_PATCH)); } - public P jsonPatchPrimary(P resource, UnaryOperator

unaryOperator, Options options) { + public P jsonPatchPrimary(P actualResource, UnaryOperator

unaryOperator, Options options) { + P desired = desiredForJsonPatch(actualResource, unaryOperator, options); return resourcePatch( - resource, - r -> context.getClient().resource(r).edit(unaryOperator), + desired, + actualResource, + r -> context.getClient().resource(actualResource).edit(unaryOperator), context.eventSourceRetriever().getControllerEventSource(), options); } @@ -534,18 +552,24 @@ public P jsonPatchPrimary(P resource, UnaryOperator

unaryOperator, Options op *

You are free to control the optimistic locking by setting the resource version in resource * metadata. * - * @param resource primary resource to patch + * @param actualResource primary resource to patch * @param unaryOperator function to modify the resource * @return updated resource */ - public P jsonPatchPrimaryStatus(P resource, UnaryOperator

unaryOperator) { - return jsonPatchPrimaryStatus(resource, unaryOperator, Options.filterIfOptimisticLocking()); + public P jsonPatchPrimaryStatus(P actualResource, UnaryOperator

unaryOperator) { + return jsonPatchPrimaryStatus( + actualResource, + unaryOperator, + Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_PATCH_STATUS)); } - public P jsonPatchPrimaryStatus(P resource, UnaryOperator

unaryOperator, Options options) { + public P jsonPatchPrimaryStatus( + P actualResource, UnaryOperator

unaryOperator, Options options) { + P desired = desiredForJsonPatch(actualResource, unaryOperator, options); return resourcePatch( - resource, - r -> context.getClient().resource(r).editStatus(unaryOperator), + desired, + actualResource, + r -> context.getClient().resource(actualResource).editStatus(unaryOperator), context.eventSourceRetriever().getControllerEventSource(), options); } @@ -682,40 +706,30 @@ public R resourcePatch(R resource, UnaryOperator upda @SuppressWarnings({"rawtypes", "unchecked"}) private R resourcePatch( R desired, UnaryOperator updateOperation, Options options) { - return resourcePatch(desired, updateOperation, options, null); + return resourcePatch(desired, null, updateOperation, options); } @Experimental(API_MIGHT_CHANGE) @SuppressWarnings({"rawtypes", "unchecked"}) private R resourcePatch( - R desired, UnaryOperator updateOperation, Options options, Matcher matcher) { - return resourcePatch(desired, null, updateOperation, options, matcher); - } + R desired, R actual, UnaryOperator updateOperation, Options options) { - @Experimental(API_MIGHT_CHANGE) - @SuppressWarnings({"rawtypes", "unchecked"}) - private R resourcePatch( - R desired, R actual, UnaryOperator updateOperation, Options options, Matcher matcher) { + Class targetClass = desired == null ? actual.getClass() : desired.getClass(); - var esList = context.eventSourceRetriever().getEventSourcesFor(desired.getClass()); + var esList = context.eventSourceRetriever().getEventSourcesFor(targetClass); if (esList.isEmpty()) { - throw new IllegalStateException("No event source found for type: " + desired.getClass()); + throw new IllegalStateException("No event source found for type: " + targetClass); } var es = esList.get(0); if (esList.size() > 1) { log.warn( "Multiple event sources found for type: {}, selecting first with name {}", - desired.getClass(), + targetClass, es.name()); } if (es instanceof ManagedInformerEventSource mes) { return resourcePatch( - desired, - actual, - updateOperation, - (ManagedInformerEventSource) mes, - options, - matcher); + desired, actual, updateOperation, (ManagedInformerEventSource) mes, options); } else { throw new IllegalStateException( "Target event source must be a subclass off " @@ -737,7 +751,7 @@ public R resourcePatch( if (options.getMatcher().isPresent()) { originalResource = ies.get(ResourceID.fromResource(desired)).orElse(null); } - return resourcePatch(desired, originalResource, updateOperation, ies, options, null); + return resourcePatch(desired, originalResource, updateOperation, ies, options); } public R resourcePatch( @@ -745,10 +759,9 @@ public R resourcePatch( R actualResource, UnaryOperator updateOperation, ManagedInformerEventSource ies, - Options options, - Matcher matcherToUse) { + Options options) { - var matcher = matcherToUse == null ? options.getMatcher().orElse(null) : matcherToUse; + var matcher = options.getMatcher().orElse(null); boolean matches = false; if (matcher != null) { if (actualResource == null) { @@ -764,7 +777,7 @@ public R resourcePatch( } boolean optimisticLocking = desiredResource.getMetadata().getResourceVersion() != null; - if (options.getMode() == Mode.ONLY_CACHE + if (options.getMode() == Mode.CACHE_ONLY || (options.getMode() == Mode.FILTER_IF_OPTIMISTIC_LOCKING && !optimisticLocking)) { return ies.updateAndCacheResource(desiredResource, updateOperation); } else { @@ -946,7 +959,7 @@ public P addFinalizerWithSSA(String finalizerName) { resource.initNameAndNamespaceFrom(originalResource); resource.addFinalizer(finalizerName); - return serverSideApplyPrimary(resource, Options.onlyCache()); + return serverSideApplyPrimary(resource, Options.cacheOnly()); } catch (InstantiationException | IllegalAccessException | InvocationTargetException @@ -962,8 +975,8 @@ public P addFinalizerWithSSA(String finalizerName) { @Experimental(API_MIGHT_CHANGE) public static class Options { - private static final Options ALWAYS_FILTER = new Options(Mode.ALWAYS_FILTER, null); - private static final Options ONLY_CACHE = new Options(Mode.ONLY_CACHE, null); + private static final Options ALWAYS_FILTER = new Options(Mode.FORCE_FILTER, null); + private static final Options ONLY_CACHE = new Options(Mode.CACHE_ONLY, null); private static final Options FILTER_IF_OPTIMISTIC_LOCKING = new Options(Mode.FILTER_IF_OPTIMISTIC_LOCKING, null); @@ -975,16 +988,16 @@ private Options(Mode mode, Matcher matcher) { this.matcher = matcher; } - public static Options alwaysFilter() { + public static Options forceFilterEvents() { return ALWAYS_FILTER; } - public static Options onlyCache() { + public static Options cacheOnly() { return ONLY_CACHE; } - public static Options onlyCache(Matcher matcher) { - return new Options(Mode.ONLY_CACHE, matcher); + public static Options cacheOnly(Matcher matcher) { + return new Options(Mode.CACHE_ONLY, matcher); } public static Options filterIfOptimisticLocking() { @@ -1016,10 +1029,6 @@ public Optional getMatcher() { return Optional.ofNullable(matcher); } - public boolean isAlwaysFilter() { - return mode == Mode.ALWAYS_FILTER; - } - public boolean requiresMatcher() { return mode == Mode.FILTER_IF_NOT_MATCHING; } @@ -1029,7 +1038,21 @@ public boolean requiresMatcher() { public enum Mode { FILTER_IF_OPTIMISTIC_LOCKING, FILTER_IF_NOT_MATCHING, - ONLY_CACHE, - ALWAYS_FILTER, + CACHE_ONLY, + FORCE_FILTER, + } + + private T desiredForJsonPatch( + T actualResource, UnaryOperator unaryOperator, Options options) { + if (options.getMatcher().isPresent()) { + var cloned = + context + .getControllerConfiguration() + .getConfigurationService() + .getResourceCloner() + .clone(actualResource); + return unaryOperator.apply(cloned); + } + return null; } } 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 be3ea501a0..bb59d6eed6 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 @@ -95,7 +95,10 @@ public R create(R desired, P primary, Context

context) { ? context.resourceOperations().serverSideApply(desired, eventSource().orElse(null)) : context .resourceOperations() - .create(desired, eventSource().orElse(null), ResourceOperations.Options.alwaysFilter()); + .create( + desired, + eventSource().orElse(null), + ResourceOperations.Options.forceFilterEvents()); } public R update(R actual, R desired, P primary, Context

context) { @@ -119,7 +122,9 @@ public R update(R actual, R desired, P primary, Context

context) { context .resourceOperations() .serverSideApply( - desired, eventSource().orElse(null), ResourceOperations.Options.alwaysFilter()); + desired, + eventSource().orElse(null), + ResourceOperations.Options.forceFilterEvents()); } else { var updatedActual = GenericResourceUpdater.updateResource(actual, desired, context); updatedResource = diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java index 18eb986efe..4993aec034 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java @@ -31,6 +31,7 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.DefaultContext; import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.ResourceOperations; import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; import io.javaoperatorsdk.operator.processing.Controller; @@ -365,9 +366,14 @@ public R patchResource(Context context, R resource, R originalResource) { log.debug("Trying to replace resource"); } if (useSSA) { - return context.resourceOperations().serverSideApplyPrimary(resource); + return context + .resourceOperations() + .serverSideApplyPrimary(resource, ResourceOperations.Options.cacheOnly()); } else { - return context.resourceOperations().jsonPatchPrimary(originalResource, r -> resource); + return context + .resourceOperations() + .jsonPatchPrimary( + originalResource, r -> resource, ResourceOperations.Options.cacheOnly()); } } @@ -377,7 +383,9 @@ public R patchStatus(Context context, R resource, R originalResource) { var managedFields = resource.getMetadata().getManagedFields(); try { resource.getMetadata().setManagedFields(null); - return context.resourceOperations().serverSideApplyPrimaryStatus(resource); + return context + .resourceOperations() + .serverSideApplyPrimaryStatus(resource, ResourceOperations.Options.cacheOnly()); } finally { resource.getMetadata().setManagedFields(managedFields); } @@ -390,6 +398,7 @@ private R editStatus(Context context, R resource, R originalResource) { String resourceVersion = resource.getMetadata().getResourceVersion(); // the cached resource should not be changed in any circumstances // that can lead to all kinds of race conditions. + // TODO review R clonedOriginal = cloner.clone(originalResource); try { clonedOriginal.getMetadata().setResourceVersion(null); @@ -397,11 +406,12 @@ private R editStatus(Context context, R resource, R originalResource) { return context .resourceOperations() .jsonPatchPrimaryStatus( - clonedOriginal, + originalResource, r -> { ReconcilerUtilsInternal.setStatus(r, ReconcilerUtilsInternal.getStatus(resource)); return r; - }); + }, + ResourceOperations.Options.cacheOnly()); } finally { // restore initial resource version clonedOriginal.getMetadata().setResourceVersion(resourceVersion); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java index 7870fa4d92..3785b3087d 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java @@ -22,11 +22,15 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.KubernetesClientException; import io.fabric8.kubernetes.client.dsl.MixedOperation; import io.fabric8.kubernetes.client.dsl.Resource; +import io.fabric8.kubernetes.client.utils.KubernetesSerialization; import io.javaoperatorsdk.operator.TestUtils; +import io.javaoperatorsdk.operator.api.config.Cloner; +import io.javaoperatorsdk.operator.api.config.ConfigurationService; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher; import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever; @@ -64,9 +68,20 @@ void setupMocks() { var eventSourceRetriever = mock(EventSourceRetriever.class); when(context.getClient()).thenReturn(client); + when(client.getKubernetesSerialization()).thenReturn(new KubernetesSerialization()); when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); when(context.getControllerConfiguration()).thenReturn(controllerConfiguration); when(controllerConfiguration.getFinalizerName()).thenReturn(FINALIZER_NAME); + var configService = mock(ConfigurationService.class); + when(controllerConfiguration.getConfigurationService()).thenReturn(configService); + when(configService.getResourceCloner()) + .thenReturn( + new Cloner() { + @Override + public R clone(R object) { + return new KubernetesSerialization().clone(object); + } + }); when(eventSourceRetriever.getControllerEventSource()).thenReturn(controllerEventSource); when(client.resources(TestCustomResource.class)).thenReturn(mixedOperation); @@ -338,8 +353,7 @@ void matcherMatchingSkipsUpdateAndReturnsActual() { actual, UnaryOperator.identity(), ies, - ResourceOperations.Options.alwaysFilter(), - matcher); + ResourceOperations.Options.matchAndFilter(matcher)); assertThat(result).isSameAs(actual); verify(ies, never()).eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); @@ -363,8 +377,7 @@ void matcherNotMatchingProceedsToEventFilteringUpdate() { actual, UnaryOperator.identity(), ies, - ResourceOperations.Options.alwaysFilter(), - matcher); + ResourceOperations.Options.matchAndFilter(matcher)); assertThat(result).isSameAs(updated); verify(ies, times(1)) @@ -385,8 +398,7 @@ void matcherRequiresActualToBePresent() { null, UnaryOperator.identity(), ies, - ResourceOperations.Options.alwaysFilter(), - matcher)); + ResourceOperations.Options.matchAndFilter(matcher))); } @Test @@ -396,7 +408,7 @@ void onlyCacheModeSkipsEventFiltering() { when(ies.updateAndCacheResource(any(), any(UnaryOperator.class))).thenReturn(desired); resourceOperations.resourcePatch( - desired, null, UnaryOperator.identity(), ies, ResourceOperations.Options.onlyCache(), null); + desired, null, UnaryOperator.identity(), ies, ResourceOperations.Options.cacheOnly()); verify(ies, times(1)).updateAndCacheResource(eq(desired), any(UnaryOperator.class)); verify(ies, never()).eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); @@ -414,8 +426,7 @@ void filterIfOptimisticLockingCachesWithoutFilteringWhenNoResourceVersion() { null, UnaryOperator.identity(), ies, - ResourceOperations.Options.filterIfOptimisticLocking(), - null); + ResourceOperations.Options.filterIfOptimisticLocking()); verify(ies, times(1)).updateAndCacheResource(eq(desired), any(UnaryOperator.class)); verify(ies, never()).eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); @@ -434,8 +445,7 @@ void filterIfOptimisticLockingFiltersWhenResourceVersionPresent() { null, UnaryOperator.identity(), ies, - ResourceOperations.Options.filterIfOptimisticLocking(), - null); + ResourceOperations.Options.filterIfOptimisticLocking()); verify(ies, times(1)) .eventFilteringUpdateAndCacheResource(eq(desired), any(UnaryOperator.class)); @@ -451,7 +461,7 @@ void createRejectsMatcher() { assertThrows( IllegalArgumentException.class, () -> - resourceOperations.create(resource, ResourceOperations.Options.onlyCache(matcher))); + resourceOperations.create(resource, ResourceOperations.Options.cacheOnly(matcher))); assertThat(exception.getMessage()).contains("does not support matcher"); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java index 2cbda9a7fe..c8bee56793 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java @@ -55,7 +55,7 @@ public UpdateControl reconcile( context .resourceOperations() - .serverSideApply(configMap(primary), ResourceOperations.Options.alwaysFilter()); + .serverSideApply(configMap(primary), ResourceOperations.Options.forceFilterEvents()); if (primary.getStatus() == null) { primary.setStatus(new ChangeNamespaceTestCustomResourceStatus()); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java index 3c4769bbcc..5f3ead43ff 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java @@ -84,7 +84,7 @@ public UpdateControl reconcile( context .resourceOperations() .serverSideApply( - cm, configMapEventSource, ResourceOperations.Options.alwaysFilter()); + cm, configMapEventSource, ResourceOperations.Options.forceFilterEvents()); case RELIST_AROUND_UPDATE -> { configMapEventSource.simulateOnBeforeList(); var applied = context.resourceOperations().serverSideApply(cm, configMapEventSource); @@ -101,7 +101,8 @@ public UpdateControl reconcile( configMapEventSource.simulateOnList(); context .resourceOperations() - .serverSideApply(cm, configMapEventSource, ResourceOperations.Options.alwaysFilter()); + .serverSideApply( + cm, configMapEventSource, ResourceOperations.Options.forceFilterEvents()); } case RELIST_STARTS_DURING_UPDATE -> { // Drive the event-filtering update path manually so we can fire onBeforeList AFTER the diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java index 265008304c..8a95f6fed8 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java @@ -56,7 +56,7 @@ public UpdateControl reconcile( .serverSideApply( prepareCM(resource, i), configMapEventSource, - ResourceOperations.Options.alwaysFilter()); + ResourceOperations.Options.forceFilterEvents()); } return UpdateControl.noUpdate(); } From a1a129bdb4fbc8a6c8775b91a72460b971a4ef88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 14 Jul 2026 09:15:08 +0200 Subject: [PATCH 17/35] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/reconciler/ResourceOperations.java | 14 +++++++------- .../api/reconciler/ResourceOperationsTest.java | 17 ----------------- 2 files changed, 7 insertions(+), 24 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 065f645e35..046d586131 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -747,11 +747,8 @@ public R resourcePatch( UnaryOperator updateOperation, ManagedInformerEventSource ies, Options options) { - R originalResource = null; - if (options.getMatcher().isPresent()) { - originalResource = ies.get(ResourceID.fromResource(desired)).orElse(null); - } - return resourcePatch(desired, originalResource, updateOperation, ies, options); + + return resourcePatch(desired, null, updateOperation, ies, options); } public R resourcePatch( @@ -765,9 +762,12 @@ public R resourcePatch( boolean matches = false; if (matcher != null) { if (actualResource == null) { - throw new IllegalArgumentException("Actual most be present for matching update/patch."); + actualResource = ies.get(ResourceID.fromResource(desiredResource)).orElse(null); + } + // todo describe might require optimistic locking + if (actualResource != null) { + matches = matcher.matches(desiredResource, actualResource, context); } - matches = matcher.matches(desiredResource, actualResource, context); } if (options.requiresMatcher() && matcher == null) { throw new IllegalArgumentException("Mode : " + options.mode + " requires matcher"); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java index 3785b3087d..7cb26ce187 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java @@ -384,23 +384,6 @@ void matcherNotMatchingProceedsToEventFilteringUpdate() { .eventFilteringUpdateAndCacheResource(eq(desired), any(UnaryOperator.class)); } - @Test - void matcherRequiresActualToBePresent() { - var desired = TestUtils.testCustomResource1(); - var ies = mock(ManagedInformerEventSource.class); - var matcher = mock(Matcher.class); - - assertThrows( - IllegalArgumentException.class, - () -> - resourceOperations.resourcePatch( - desired, - null, - UnaryOperator.identity(), - ies, - ResourceOperations.Options.matchAndFilter(matcher))); - } - @Test void onlyCacheModeSkipsEventFiltering() { var desired = TestUtils.testCustomResource1(); From b16578ad70f87fffcf21a6a04f5e0e172749b5df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 14 Jul 2026 09:57:29 +0200 Subject: [PATCH 18/35] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/api/reconciler/ResourceOperations.java | 7 ------- 1 file changed, 7 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 046d586131..865402a4fb 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -695,13 +695,6 @@ public R resourcePatch(R resource, UnaryOperator upda return resourcePatch(resource, updateOperation, Options.filterIfOptimisticLocking()); } - /** - * @param desired resource to patch - * @param updateOperation operation to perform (update, patch, edit, etc.) - * @return updated resource - * @param resource type - * @throws IllegalStateException if no event source or multiple event sources are found - */ @Experimental(API_MIGHT_CHANGE) @SuppressWarnings({"rawtypes", "unchecked"}) private R resourcePatch( From a62d6a570e0ad146307fa5df26891e104cfda70f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 14 Jul 2026 10:08:48 +0200 Subject: [PATCH 19/35] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/api/reconciler/ResourceOperations.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 865402a4fb..0fa1e1dac6 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -94,7 +94,8 @@ public R serverSideApply(R resource, Options options) { public R serverSideApply( R resource, InformerEventSource informerEventSource) { - return serverSideApply(resource, informerEventSource, null); + return serverSideApply( + resource, informerEventSource, Options.matchAndFilterWithDefaultMatcher(UpdateType.SSA)); } /** From b964031e2d0e9c1af27ad8ee0001547e4ec497e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 14 Jul 2026 10:57:40 +0200 Subject: [PATCH 20/35] Integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../blog/news/read-after-write-consistency.md | 36 ++-- .../reconciler/matcher/PatchMatchersTest.java | 168 ++++++++++++++++++ .../matcher/StatusMatchersTest.java | 89 ++++++++++ .../ResourceOperationsCustomResource.java | 29 +++ .../ResourceOperationsIT.java | 150 ++++++++++++++++ .../ResourceOperationsReconciler.java | 113 ++++++++++++ .../ResourceOperationsSpec.java | 30 ++++ .../ResourceOperationsStatus.java | 30 ++++ .../operator/sample/WebPageReconciler.java | 1 + 9 files changed, 635 insertions(+), 11 deletions(-) create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/matcher/PatchMatchersTest.java create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/matcher/StatusMatchersTest.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsReconciler.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsSpec.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsStatus.java diff --git a/docs/content/en/blog/news/read-after-write-consistency.md b/docs/content/en/blog/news/read-after-write-consistency.md index 29e3c5f626..36edf97693 100644 --- a/docs/content/en/blog/news/read-after-write-consistency.md +++ b/docs/content/en/blog/news/read-after-write-consistency.md @@ -30,8 +30,8 @@ public UpdateControl reconcile(WebPage webPage, Context contex } ``` -In addition to that, the framework will automatically filter events for your own updates, -so they don't trigger the reconciliation again. +In addition to that, the framework will provide facilities to filter out +events for own updates so they don't trigger the reconciliation again. {{% alert color=success %}} **This should significantly simplify controller development, and will make reconciliation @@ -180,12 +180,6 @@ From this point the idea of the algorithm is very simple: the one in the TRC. If yes, evict the resource from the TRC. 3. When the controller reads a resource from cache, it checks the TRC first, then falls back to the Informer's cache. -The actual filtering of events for our own writes is more nuanced than a simple -"evict on RV ≥ TRC version" rule — it is driven by a per-resource state machine -that tracks in-flight writes and the events received around them. See -[Filtering events for our own updates](#filtering-events-for-our-own-updates) below. - - ```mermaid sequenceDiagram box rgba(50,108,229,0.1) @@ -226,10 +220,30 @@ sequenceDiagram When we update a resource, the informer will eventually propagate an event that would trigger a reconciliation. In most cases, however, this is not desirable. Since we already have the up-to-date resource at that point, we want to be notified only when the change originates outside our reconciler. -Therefore, in addition to caching the resource, we filter out events caused by our own updates. +Therefore, in addition to caching the resource, provide utilities to so you can optimize the update/patch +operations to filter out those events. + +See [ResourceOperations](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java#L49) +for details. + +```java +public UpdateControl reconcile(WebPage webPage, Context context) { + + ConfigMap managedConfigMap = prepareConfigMap(webPage); + + // resource operation in this case will resource only if + // it does not match the actual, and will filter our the related event + context.resourceOperations().serverSideApply(managedConfigMap); + + // UpdateControl.patchStatus would only cache the resource to filter out events too + // you have to use resourceOperations. + context.resourceOperations().serverSideApplyPrimaryStatus(alterStatusObject(webPage)); + return UpdateControl.noUpdate(); +} +``` -Note that the implementation of this is relatively complex: while performing the update, we record all the -events received in the meantime and decide whether to propagate them further once the update request completes. +Note that the implementation of this is relatively complex and has some caveats: +while performing the update, we record all the events received in the meantime and decide whether to propagate them further once the update request completes. This way, we significantly reduce the number of reconciliations, making the whole process much more efficient. diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/matcher/PatchMatchersTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/matcher/PatchMatchersTest.java new file mode 100644 index 0000000000..9bf1171100 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/matcher/PatchMatchersTest.java @@ -0,0 +1,168 @@ +/* + * 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.api.reconciler.matcher; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.MockKubernetesClient; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.DefaultContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class PatchMatchersTest { + + private static final Context context = new TestContext(); + + private final JsonPatchMacher jsonPatchMatcher = JsonPatchMacher.getInstance(); + private final JsonMergePatchMatcher mergePatchMatcher = JsonMergePatchMatcher.getInstance(); + private final JsonPatchStatusMacher jsonPatchStatusMatcher = JsonPatchStatusMacher.getInstance(); + private final JsonMergePatchStatusMatcher mergePatchStatusMatcher = + JsonMergePatchStatusMatcher.getInstance(); + + // ---- JSON Patch (whole resource) ---- + + @Test + void jsonPatchMatchesIdenticalResources() { + assertThat(jsonPatchMatcher.matches(deployment(), deployment(), context)).isTrue(); + } + + @Test + void jsonPatchDoesNotMatchWhenActualHasAdditionalField() { + var actual = deployment(); + actual.getMetadata().getLabels().put("extra", "value"); + assertThat(jsonPatchMatcher.matches(deployment(), actual, context)).isFalse(); + } + + @Test + void jsonPatchDoesNotMatchWhenDesiredHasAdditionalField() { + var desired = deployment(); + desired.getMetadata().getLabels().put("extra", "value"); + assertThat(jsonPatchMatcher.matches(desired, deployment(), context)).isFalse(); + } + + @Test + void jsonPatchDoesNotMatchOnDifferentValue() { + var desired = deployment(); + desired.getSpec().setReplicas(5); + assertThat(jsonPatchMatcher.matches(desired, deployment(), context)).isFalse(); + } + + // ---- JSON Merge Patch (whole resource) ---- + + @Test + void mergePatchMatchesIdenticalResources() { + assertThat(mergePatchMatcher.matches(deployment(), deployment(), context)).isTrue(); + } + + @Test + void mergePatchMatchesWhenActualHasAdditionalField() { + // a merge patch of the desired state leaves fields only present in the actual state untouched + var actual = deployment(); + actual.getMetadata().getLabels().put("extra", "value"); + assertThat(mergePatchMatcher.matches(deployment(), actual, context)).isTrue(); + } + + @Test + void mergePatchDoesNotMatchWhenDesiredHasAdditionalField() { + var desired = deployment(); + desired.getMetadata().getLabels().put("extra", "value"); + assertThat(mergePatchMatcher.matches(desired, deployment(), context)).isFalse(); + } + + @Test + void mergePatchDoesNotMatchOnDifferentValue() { + var desired = deployment(); + desired.getSpec().setReplicas(5); + assertThat(mergePatchMatcher.matches(desired, deployment(), context)).isFalse(); + } + + // ---- Status matchers ---- + + @Test + void statusMatchersIgnoreChangesOutsideStatus() { + var desired = deploymentWithStatus(); + desired.getSpec().setReplicas(5); + var actual = deploymentWithStatus(); + assertThat(jsonPatchStatusMatcher.matches(desired, actual, context)).isTrue(); + assertThat(mergePatchStatusMatcher.matches(desired, actual, context)).isTrue(); + } + + @Test + void statusMatchersDoNotMatchOnDifferentStatus() { + var desired = deploymentWithStatus(); + desired.getStatus().setReplicas(9); + var actual = deploymentWithStatus(); + assertThat(jsonPatchStatusMatcher.matches(desired, actual, context)).isFalse(); + assertThat(mergePatchStatusMatcher.matches(desired, actual, context)).isFalse(); + } + + @Test + void statusMatchersDifferOnAdditionalActualStatusField() { + var actual = deploymentWithStatus(); + actual.getStatus().setAvailableReplicas(1); + var desired = deploymentWithStatus(); + + // json patch sees the extra actual field as a removal, so it does not match + assertThat(jsonPatchStatusMatcher.matches(desired, actual, context)).isFalse(); + // merge patch tolerates fields only present in the actual state + assertThat(mergePatchStatusMatcher.matches(desired, actual, context)).isTrue(); + } + + @Test + void statusMatchersMatchWhenBothStatusesAbsent() { + assertThat(jsonPatchStatusMatcher.matches(deployment(), deployment(), context)).isTrue(); + assertThat(mergePatchStatusMatcher.matches(deployment(), deployment(), context)).isTrue(); + } + + private static Deployment deployment() { + return new DeploymentBuilder() + .withNewMetadata() + .withName("test") + .withNamespace("default") + .addToLabels("app", "test") + .endMetadata() + .withNewSpec() + .withReplicas(1) + .endSpec() + .build(); + } + + private static Deployment deploymentWithStatus() { + var deployment = deployment(); + deployment.setStatus(new io.fabric8.kubernetes.api.model.apps.DeploymentStatus()); + deployment.getStatus().setReplicas(1); + return deployment; + } + + private static class TestContext extends DefaultContext { + private final KubernetesClient client = MockKubernetesClient.client(HasMetadata.class); + + TestContext() { + super(mock(), mock(), null, false, false); + } + + @Override + public KubernetesClient getClient() { + return client; + } + } +} diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/matcher/StatusMatchersTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/matcher/StatusMatchersTest.java new file mode 100644 index 0000000000..aa1f8bf63d --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/matcher/StatusMatchersTest.java @@ -0,0 +1,89 @@ +/* + * 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.api.reconciler.matcher; + +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder; +import io.fabric8.kubernetes.api.model.apps.DeploymentStatusBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.MockKubernetesClient; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.DefaultContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class StatusMatchersTest { + + private static final Context context = new TestContext(); + + private final UpdateStatusMatcher updateStatusMatcher = UpdateStatusMatcher.getInstance(); + private final SSAStatusMatcher ssaStatusMatcher = SSAStatusMatcher.getInstance(); + + @Test + void matchesEqualStatus() { + var desired = deploymentWithStatus(1); + var actual = deploymentWithStatus(1); + assertThat(updateStatusMatcher.matches(desired, actual, context)).isTrue(); + assertThat(ssaStatusMatcher.matches(desired, actual, context)).isTrue(); + } + + @Test + void doesNotMatchDifferentStatus() { + var desired = deploymentWithStatus(1); + var actual = deploymentWithStatus(2); + assertThat(updateStatusMatcher.matches(desired, actual, context)).isFalse(); + assertThat(ssaStatusMatcher.matches(desired, actual, context)).isFalse(); + } + + @Test + void ignoresChangesOutsideStatus() { + var desired = deploymentWithStatus(1); + desired.getSpec().setReplicas(5); + var actual = deploymentWithStatus(1); + assertThat(updateStatusMatcher.matches(desired, actual, context)).isTrue(); + assertThat(ssaStatusMatcher.matches(desired, actual, context)).isTrue(); + } + + private static Deployment deploymentWithStatus(int statusReplicas) { + return new DeploymentBuilder() + .withNewMetadata() + .withName("test") + .withNamespace("default") + .endMetadata() + .withNewSpec() + .withReplicas(1) + .endSpec() + .withStatus(new DeploymentStatusBuilder().withReplicas(statusReplicas).build()) + .build(); + } + + private static class TestContext extends DefaultContext { + private final KubernetesClient client = MockKubernetesClient.client(HasMetadata.class); + + TestContext() { + super(mock(), mock(), null, false, false); + } + + @Override + public KubernetesClient getClient() { + return client; + } + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsCustomResource.java new file mode 100644 index 0000000000..93e2ebe7fc --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsCustomResource.java @@ -0,0 +1,29 @@ +/* + * 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.baseapi.resourceoperations; + +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("ropres") +public class ResourceOperationsCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsIT.java new file mode 100644 index 0000000000..d655ef7e4f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsIT.java @@ -0,0 +1,150 @@ +/* + * 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.baseapi.resourceoperations; + +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.baseapi.resourceoperations.ResourceOperationsReconciler.Operation; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.resourceoperations.ResourceOperationsReconciler.SPEC_MARKER; +import static io.javaoperatorsdk.operator.baseapi.resourceoperations.ResourceOperationsReconciler.STATUS_VALUE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Integration test that drives every primary update/patch operation exposed by {@link + * io.javaoperatorsdk.operator.api.reconciler.ResourceOperations} against a real cluster through the + * reconciler. For each operation it asserts: + * + *

    + *
  • the intended change is actually persisted on the server (spec marker or status value), and + *
  • the operation converges: since every operation uses its default matcher and filters its own + * event, the controller must not loop on the write it just performed. + *
+ */ +class ResourceOperationsIT { + + static final String RESOURCE_NAME = "test-resource"; + static final String INITIAL_SPEC_VALUE = "initial"; + + ResourceOperationsReconciler reconciler = new ResourceOperationsReconciler(); + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder().withReconciler(reconciler).build(); + + @Test + void serverSideApply() { + assertSpecOperation(Operation.SSA); + } + + @Test + void serverSideApplyStatus() { + assertStatusOperation(Operation.SSA_STATUS); + } + + @Test + void update() { + assertSpecOperation(Operation.UPDATE); + } + + @Test + void updateStatus() { + assertStatusOperation(Operation.UPDATE_STATUS); + } + + @Test + void jsonPatch() { + assertSpecOperation(Operation.JSON_PATCH); + } + + @Test + void jsonPatchStatus() { + assertStatusOperation(Operation.JSON_PATCH_STATUS); + } + + @Test + void jsonMergePatch() { + assertSpecOperation(Operation.JSON_MERGE_PATCH); + } + + @Test + void jsonMergePatchStatus() { + assertStatusOperation(Operation.JSON_MERGE_PATCH_STATUS); + } + + private void assertSpecOperation(Operation operation) { + runOperation(operation); + await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> + assertThat(actual().getSpec().getValue()) + .as("operation %s should persist the spec change", operation) + .isEqualTo(SPEC_MARKER)); + assertConvergesWithoutLooping(operation); + } + + private void assertStatusOperation(Operation operation) { + runOperation(operation); + await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + assertThat(actual().getStatus()).isNotNull(); + assertThat(actual().getStatus().getValue()) + .as("operation %s should persist the status value", operation) + .isEqualTo(STATUS_VALUE); + }); + assertConvergesWithoutLooping(operation); + } + + private void runOperation(Operation operation) { + reconciler.setOperation(operation); + extension.create(testResource()); + } + + /** + * The write must be filtered as an own event and any further reconciliation must match the + * desired state, so the controller does not loop on its own write. + */ + private void assertConvergesWithoutLooping(Operation operation) { + await() + .during(Duration.ofSeconds(2)) + .atMost(Duration.ofSeconds(5)) + .untilAsserted( + () -> + assertThat(reconciler.numberOfExecutions.get()) + .as("operation %s should converge without triggering an event loop", operation) + .isLessThanOrEqualTo(3)); + } + + private ResourceOperationsCustomResource actual() { + return extension.get(ResourceOperationsCustomResource.class, RESOURCE_NAME); + } + + ResourceOperationsCustomResource testResource() { + var r = new ResourceOperationsCustomResource(); + r.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build()); + r.setSpec(new ResourceOperationsSpec().setValue(INITIAL_SPEC_VALUE)); + return r; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsReconciler.java new file mode 100644 index 0000000000..d747b515a9 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsReconciler.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.baseapi.resourceoperations; + +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; + +/** + * Exercises every primary update/patch operation exposed by {@link + * io.javaoperatorsdk.operator.api.reconciler.ResourceOperations}. On each reconciliation it applies + * the change dictated by the currently selected {@link Operation} using {@code + * context.resourceOperations()} and then returns {@link UpdateControl#noUpdate()} (the write was + * already performed via the operations helper). + * + *

Status operations write a fixed value into the status subresource; whole-resource operations + * write a fixed value into the spec. Every operation uses its default matcher, so once the change + * is present on the server subsequent reconciliations must match and become no-ops - which, + * together with own event filtering, is what keeps the controller from looping on its own writes. + */ +@ControllerConfiguration +public class ResourceOperationsReconciler implements Reconciler { + + public static final String STATUS_VALUE = "reconciled"; + public static final String SPEC_MARKER = "applied"; + + public enum Operation { + SSA, + SSA_STATUS, + UPDATE, + UPDATE_STATUS, + JSON_PATCH, + JSON_PATCH_STATUS, + JSON_MERGE_PATCH, + JSON_MERGE_PATCH_STATUS + } + + private volatile Operation operation; + final AtomicInteger numberOfExecutions = new AtomicInteger(); + + @Override + public UpdateControl reconcile( + ResourceOperationsCustomResource resource, + Context context) { + numberOfExecutions.incrementAndGet(); + var ops = context.resourceOperations(); + + switch (operation) { + case SSA -> { + markResource(resource); + // SSA does not use optimistic locking + resource.getMetadata().setResourceVersion(null); + ops.serverSideApplyPrimary(resource); + } + case SSA_STATUS -> { + resource.setStatus(new ResourceOperationsStatus().setValue(STATUS_VALUE)); + resource.getMetadata().setResourceVersion(null); + ops.serverSideApplyPrimaryStatus(resource); + } + case UPDATE -> { + markResource(resource); + ops.updatePrimary(resource); + } + case UPDATE_STATUS -> { + resource.setStatus(new ResourceOperationsStatus().setValue(STATUS_VALUE)); + ops.updatePrimaryStatus(resource); + } + case JSON_PATCH -> ops.jsonPatchPrimary(resource, ResourceOperationsReconciler::markResource); + case JSON_PATCH_STATUS -> + ops.jsonPatchPrimaryStatus( + resource, + r -> { + r.setStatus(new ResourceOperationsStatus().setValue(STATUS_VALUE)); + return r; + }); + case JSON_MERGE_PATCH -> { + markResource(resource); + ops.jsonMergePatchPrimary(resource); + } + case JSON_MERGE_PATCH_STATUS -> { + resource.setStatus(new ResourceOperationsStatus().setValue(STATUS_VALUE)); + ops.jsonMergePatchPrimaryStatus(resource); + } + } + return UpdateControl.noUpdate(); + } + + private static ResourceOperationsCustomResource markResource( + ResourceOperationsCustomResource resource) { + resource.getSpec().setValue(SPEC_MARKER); + return resource; + } + + public void setOperation(Operation operation) { + this.operation = operation; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsSpec.java new file mode 100644 index 0000000000..50ce143d7e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsSpec.java @@ -0,0 +1,30 @@ +/* + * 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.baseapi.resourceoperations; + +public class ResourceOperationsSpec { + + private String value; + + public String getValue() { + return value; + } + + public ResourceOperationsSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsStatus.java new file mode 100644 index 0000000000..fdb046b5c0 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsStatus.java @@ -0,0 +1,30 @@ +/* + * 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.baseapi.resourceoperations; + +public class ResourceOperationsStatus { + + private String value; + + public String getValue() { + return value; + } + + public ResourceOperationsStatus setValue(String value) { + this.value = value; + return this; + } +} diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java index eba68d9381..dafde7dab6 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java @@ -133,6 +133,7 @@ private T createOrUpdate( desired.getMetadata().getName(), desired.getMetadata().getNamespace()); context.resourceOperations().serverSideApply(desired); + } return previous; } From b7bdd97d4a83657e4480232d14c63e342ec2fa21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 14 Jul 2026 13:15:36 +0200 Subject: [PATCH 21/35] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/reconciler/ResourceOperations.java | 10 +- .../event/ReconciliationDispatcher.java | 3 +- ...ndaryResourceOperationsCustomResource.java | 29 ++++ .../SecondaryResourceOperationsIT.java | 120 ++++++++++++++ ...SecondaryResourceOperationsReconciler.java | 154 ++++++++++++++++++ .../operator/sample/WebPageReconciler.java | 1 - 6 files changed, 310 insertions(+), 7 deletions(-) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/SecondaryResourceOperationsCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/SecondaryResourceOperationsIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/SecondaryResourceOperationsReconciler.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 0fa1e1dac6..e92ce89387 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -570,7 +570,7 @@ public P jsonPatchPrimaryStatus( return resourcePatch( desired, actualResource, - r -> context.getClient().resource(actualResource).editStatus(unaryOperator), + r -> context.getClient().resource(actualResource).status().edit(unaryOperator), context.eventSourceRetriever().getControllerEventSource(), options); } @@ -769,13 +769,15 @@ public R resourcePatch( if (matches) { return actualResource; } - boolean optimisticLocking = desiredResource.getMetadata().getResourceVersion() != null; + var targetBaseResource = desiredResource != null ? desiredResource : actualResource; + + boolean optimisticLocking = targetBaseResource.getMetadata().getResourceVersion() != null; if (options.getMode() == Mode.CACHE_ONLY || (options.getMode() == Mode.FILTER_IF_OPTIMISTIC_LOCKING && !optimisticLocking)) { - return ies.updateAndCacheResource(desiredResource, updateOperation); + return ies.updateAndCacheResource(targetBaseResource, updateOperation); } else { - return ies.eventFilteringUpdateAndCacheResource(desiredResource, updateOperation); + return ies.eventFilteringUpdateAndCacheResource(targetBaseResource, updateOperation); } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java index 4993aec034..f826ad06d0 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java @@ -398,7 +398,6 @@ private R editStatus(Context context, R resource, R originalResource) { String resourceVersion = resource.getMetadata().getResourceVersion(); // the cached resource should not be changed in any circumstances // that can lead to all kinds of race conditions. - // TODO review R clonedOriginal = cloner.clone(originalResource); try { clonedOriginal.getMetadata().setResourceVersion(null); @@ -406,7 +405,7 @@ private R editStatus(Context context, R resource, R originalResource) { return context .resourceOperations() .jsonPatchPrimaryStatus( - originalResource, + clonedOriginal, r -> { ReconcilerUtilsInternal.setStatus(r, ReconcilerUtilsInternal.getStatus(resource)); return r; diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/SecondaryResourceOperationsCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/SecondaryResourceOperationsCustomResource.java new file mode 100644 index 0000000000..787495a3ca --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/SecondaryResourceOperationsCustomResource.java @@ -0,0 +1,29 @@ +/* + * 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.baseapi.resourceoperations; + +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("secropres") +public class SecondaryResourceOperationsCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/SecondaryResourceOperationsIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/SecondaryResourceOperationsIT.java new file mode 100644 index 0000000000..346a264da1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/SecondaryResourceOperationsIT.java @@ -0,0 +1,120 @@ +/* + * 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.baseapi.resourceoperations; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.baseapi.resourceoperations.SecondaryResourceOperationsReconciler.Operation; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.resourceoperations.SecondaryResourceOperationsReconciler.APPLIED_VALUE; +import static io.javaoperatorsdk.operator.baseapi.resourceoperations.SecondaryResourceOperationsReconciler.CREATE_VALUE; +import static io.javaoperatorsdk.operator.baseapi.resourceoperations.SecondaryResourceOperationsReconciler.DATA_KEY; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Integration test for the secondary-resource variants of {@link + * io.javaoperatorsdk.operator.api.reconciler.ResourceOperations} (the overloads taking an {@code + * InformerEventSource}). For each operation it asserts: + * + *

    + *
  • the managed {@code ConfigMap} secondary is created/updated to the expected value, and + *
  • the write on the secondary is filtered as an own event, so the controller converges and + * does not loop on its own secondary write. + *
+ */ +class SecondaryResourceOperationsIT { + + static final String RESOURCE_NAME = "test-resource"; + + SecondaryResourceOperationsReconciler reconciler = new SecondaryResourceOperationsReconciler(); + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder().withReconciler(reconciler).build(); + + @Test + void create() { + reconciler.setOperation(Operation.CREATE); + extension.create(testResource()); + awaitConfigMapValue(CREATE_VALUE); + assertConvergesWithoutLooping(Operation.CREATE); + } + + @Test + void serverSideApply() { + assertAppliedOperation(Operation.SSA); + } + + @Test + void update() { + assertAppliedOperation(Operation.UPDATE); + } + + @Test + void jsonPatch() { + assertAppliedOperation(Operation.JSON_PATCH); + } + + @Test + void jsonMergePatch() { + assertAppliedOperation(Operation.JSON_MERGE_PATCH); + } + + private void assertAppliedOperation(Operation operation) { + reconciler.setOperation(operation); + extension.create(testResource()); + awaitConfigMapValue(APPLIED_VALUE); + assertConvergesWithoutLooping(operation); + } + + private void awaitConfigMapValue(String expected) { + await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + var cm = extension.get(ConfigMap.class, RESOURCE_NAME); + assertThat(cm).isNotNull(); + assertThat(cm.getData()).containsEntry(DATA_KEY, expected); + }); + } + + private void assertConvergesWithoutLooping(Operation operation) { + await() + .during(Duration.ofSeconds(2)) + .atMost(Duration.ofSeconds(5)) + .untilAsserted( + () -> + assertThat(reconciler.numberOfExecutions.get()) + .as( + "operation %s should converge without looping on its own secondary write", + operation) + .isLessThanOrEqualTo(4)); + } + + SecondaryResourceOperationsCustomResource testResource() { + var r = new SecondaryResourceOperationsCustomResource(); + r.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build()); + r.setSpec(new ResourceOperationsSpec().setValue("initial")); + return r; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/SecondaryResourceOperationsReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/SecondaryResourceOperationsReconciler.java new file mode 100644 index 0000000000..6a694c9751 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/SecondaryResourceOperationsReconciler.java @@ -0,0 +1,154 @@ +/* + * 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.baseapi.resourceoperations; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.ResourceOperations.Options; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +/** + * Exercises the secondary-resource variants of {@link + * io.javaoperatorsdk.operator.api.reconciler.ResourceOperations} - the overloads that take an + * {@link InformerEventSource} so the written secondary is cached (and its own event filtered) + * against that source. On every reconciliation it ensures a {@code ConfigMap} secondary exists with + * the value dictated by the selected {@link Operation}, using {@code context.resourceOperations()}. + * + *

The operations are applied idempotently: the secondary is only written when missing or when + * its data differs from the desired value. Together with own event filtering this keeps the + * controller from looping on the secondary writes it performs itself. + */ +@ControllerConfiguration +public class SecondaryResourceOperationsReconciler + implements Reconciler { + + public static final String DATA_KEY = "value"; + public static final String CREATE_VALUE = "created"; + public static final String APPLIED_VALUE = "applied"; + + public enum Operation { + CREATE, + SSA, + UPDATE, + JSON_PATCH, + JSON_MERGE_PATCH + } + + private volatile Operation operation; + final AtomicInteger numberOfExecutions = new AtomicInteger(); + + private InformerEventSource + configMapEventSource; + + @Override + public UpdateControl reconcile( + SecondaryResourceOperationsCustomResource resource, + Context context) { + numberOfExecutions.incrementAndGet(); + var ops = context.resourceOperations(); + var actual = context.getSecondaryResource(ConfigMap.class).orElse(null); + + if (operation == Operation.CREATE) { + if (actual == null) { + ops.create( + desiredConfigMap(resource, CREATE_VALUE), configMapEventSource, Options.cacheOnly()); + } + return UpdateControl.noUpdate(); + } + + // for the update/patch variants the secondary must exist first + if (actual == null) { + ops.create( + desiredConfigMap(resource, CREATE_VALUE), configMapEventSource, Options.cacheOnly()); + return UpdateControl.noUpdate(); + } + + // idempotency guard: only write when the secondary does not yet hold the desired value + if (APPLIED_VALUE.equals(actual.getData().get(DATA_KEY))) { + return UpdateControl.noUpdate(); + } + + switch (operation) { + case SSA -> { + var desired = desiredConfigMap(resource, APPLIED_VALUE); + desired.getMetadata().setResourceVersion(null); + ops.serverSideApply(desired, configMapEventSource, Options.forceFilterEvents()); + } + case UPDATE -> { + actual.getData().put(DATA_KEY, APPLIED_VALUE); + ops.update(actual, configMapEventSource, Options.filterIfOptimisticLocking()); + } + case JSON_PATCH -> + ops.jsonPatch( + actual, + cm -> { + cm.getData().put(DATA_KEY, APPLIED_VALUE); + return cm; + }, + configMapEventSource, + Options.filterIfOptimisticLocking()); + case JSON_MERGE_PATCH -> { + var desired = desiredConfigMap(resource, APPLIED_VALUE); + ops.jsonMergePatch(desired, configMapEventSource, Options.filterIfOptimisticLocking()); + } + default -> throw new IllegalStateException("Unexpected operation: " + operation); + } + return UpdateControl.noUpdate(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + configMapEventSource = + new InformerEventSource<>( + InformerEventSourceConfiguration.from( + ConfigMap.class, SecondaryResourceOperationsCustomResource.class) + .build(), + context); + return List.of(configMapEventSource); + } + + private static ConfigMap desiredConfigMap( + SecondaryResourceOperationsCustomResource primary, String value) { + var cm = + new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(primary.getMetadata().getName()) + .withNamespace(primary.getMetadata().getNamespace()) + .build()) + .withData(Map.of(DATA_KEY, value)) + .build(); + cm.addOwnerReference(primary); + return cm; + } + + public void setOperation(Operation operation) { + this.operation = operation; + } +} diff --git a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java index dafde7dab6..eba68d9381 100644 --- a/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java +++ b/sample-operators/webpage/src/main/java/io/javaoperatorsdk/operator/sample/WebPageReconciler.java @@ -133,7 +133,6 @@ private T createOrUpdate( desired.getMetadata().getName(), desired.getMetadata().getNamespace()); context.resourceOperations().serverSideApply(desired); - } return previous; } From 42796cc665fc3b81aa82f0201471ee49c0e8ced1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 14 Jul 2026 13:20:11 +0200 Subject: [PATCH 22/35] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/baseapi/subresource/SubResourceUpdateIT.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceUpdateIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceUpdateIT.java index a86220439c..1ea9ca96ce 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceUpdateIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceUpdateIT.java @@ -59,7 +59,7 @@ void updatesSubResourceStatus() { // wait for sure, there are no more events waitXms(WAIT_AFTER_EXECUTION); // there is no event on status update processed - assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(1); + assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(2); } @Test @@ -73,7 +73,7 @@ void updatesSubResourceStatusNoFinalizer() { // wait for sure, there are no more events waitXms(WAIT_AFTER_EXECUTION); // there is no event on status update processed - assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(1); + assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(2); } /** Note that we check on controller impl if there is finalizer on execution. */ @@ -87,7 +87,7 @@ void ifNoFinalizerPresentFirstAddsTheFinalizerThenExecutesControllerAgain() { // wait for sure, there are no more events waitXms(WAIT_AFTER_EXECUTION); // there is no event on status update processed - assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(1); + assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(2); } /** From bcadb2c12fbfbdf1c38a220f846fdc3b4ea49f01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 14 Jul 2026 13:37:16 +0200 Subject: [PATCH 23/35] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- docs/content/en/blog/releases/v5-5-release.md | 36 ++++++ .../api/reconciler/ResourceOperations.java | 4 +- ...ternalUpdateDuringOwnUpdateReconciler.java | 23 +++- .../OwnSsaStatusUpdateCustomResource.java | 28 +++++ .../OwnSsaStatusUpdateIT.java | 103 ++++++++++++++++++ .../OwnSsaStatusUpdateReconciler.java | 66 +++++++++++ .../OwnSsaStatusUpdateStatus.java | 30 +++++ 7 files changed, 287 insertions(+), 3 deletions(-) create mode 100644 docs/content/en/blog/releases/v5-5-release.md create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownssastatusupdate/OwnSsaStatusUpdateCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownssastatusupdate/OwnSsaStatusUpdateIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownssastatusupdate/OwnSsaStatusUpdateReconciler.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownssastatusupdate/OwnSsaStatusUpdateStatus.java diff --git a/docs/content/en/blog/releases/v5-5-release.md b/docs/content/en/blog/releases/v5-5-release.md new file mode 100644 index 0000000000..1c7da20ecc --- /dev/null +++ b/docs/content/en/blog/releases/v5-5-release.md @@ -0,0 +1,36 @@ +--- +title: Version 5.5 Released! +date: 2026-07-14 +author: >- + [Attila Mészáros](https://github.com/csviri) +--- + +We're pleased to announce the release of Java Operator SDK v5.5.0! + +## Key Features + +## Additional Improvements + +## Migration Notes + +## Getting Started + +```xml + + io.javaoperatorsdk + operator-framework + 5.5.0 + +``` + +## All Changes + +See the [comparison view](https://github.com/operator-framework/java-operator-sdk/compare/v5.4.0...v5.5.0) +for the full list of changes. + +## Feedback + +Please report issues or suggest improvements on our +[GitHub repository](https://github.com/operator-framework/java-operator-sdk/issues). + +Happy operator building! 🚀 diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index e92ce89387..2fae340cbb 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -733,7 +733,7 @@ private R resourcePatch( public R resourcePatch( R desired, UnaryOperator updateOperation, ManagedInformerEventSource ies) { - return resourcePatch(desired, updateOperation, Options.filterIfOptimisticLocking()); + return resourcePatch(desired, updateOperation, ies, Options.filterIfOptimisticLocking()); } public R resourcePatch( @@ -1031,10 +1031,12 @@ public boolean requiresMatcher() { } @Experimental(API_MIGHT_CHANGE) + /** */ public enum Mode { FILTER_IF_OPTIMISTIC_LOCKING, FILTER_IF_NOT_MATCHING, CACHE_ONLY, + /** */ FORCE_FILTER, } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateReconciler.java index 2352a3f94b..e5c956dc4e 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateReconciler.java @@ -16,6 +16,7 @@ package io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.externalupdateduringownupdate; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -46,10 +47,28 @@ public UpdateControl reconcile( if (execution == 1) { var status = new ExternalUpdateDuringOwnUpdateStatus().setValue(STATUS_VALUE); resource.setStatus(status); + // wrap our own status update in resourcePatch with a hook that lets the test // perform an external metadata update WHILE our filter window is still open. - resource.getMetadata().setResourceVersion(null); - context.resourceOperations().jsonMergePatchPrimary(resource); + context + .resourceOperations() + .resourcePatch( + resource, + r -> { + updateStartedLatch.countDown(); + try { + if (!externalUpdateDoneLatch.await(30, TimeUnit.SECONDS)) { + throw new RuntimeException("timed out waiting for external update"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + // server-side state moved due to the external label change; drop our stale rv + r.getMetadata().setResourceVersion(null); + return context.getClient().resource(r).patchStatus(); + }, + context.eventSourceRetriever().getControllerEventSource()); } else { var labels = resource.getMetadata().getLabels(); if (labels != null && EXTERNAL_LABEL_VALUE.equals(labels.get(EXTERNAL_LABEL_KEY))) { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownssastatusupdate/OwnSsaStatusUpdateCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownssastatusupdate/OwnSsaStatusUpdateCustomResource.java new file mode 100644 index 0000000000..e44c863aec --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownssastatusupdate/OwnSsaStatusUpdateCustomResource.java @@ -0,0 +1,28 @@ +/* + * 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.baseapi.readcacheafterwrite.ownssastatusupdate; + +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("ossasu") +public class OwnSsaStatusUpdateCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownssastatusupdate/OwnSsaStatusUpdateIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownssastatusupdate/OwnSsaStatusUpdateIT.java new file mode 100644 index 0000000000..fc00cc3fae --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownssastatusupdate/OwnSsaStatusUpdateIT.java @@ -0,0 +1,103 @@ +/* + * 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.baseapi.readcacheafterwrite.ownssastatusupdate; + +import java.time.Duration; +import java.util.HashMap; + +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 static io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.ownssastatusupdate.OwnSsaStatusUpdateReconciler.EXTERNAL_LABEL_KEY; +import static io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.ownssastatusupdate.OwnSsaStatusUpdateReconciler.EXTERNAL_LABEL_VALUE; +import static io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.ownssastatusupdate.OwnSsaStatusUpdateReconciler.STATUS_VALUE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Verifies that updating the status through {@code + * resourceOperations().serverSideApplyPrimaryStatus(...)} (instead of returning an {@code + * UpdateControl}) is read-cache-after-write consistent and does not loop: the own SSA status write + * is filtered as an own event, so the controller converges. A subsequent external label change must + * still be picked up by a fresh reconciliation. + */ +class OwnSsaStatusUpdateIT { + + static final String RESOURCE_NAME = "test-resource"; + + OwnSsaStatusUpdateReconciler reconciler = new OwnSsaStatusUpdateReconciler(); + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder().withReconciler(reconciler).build(); + + @Test + void ssaStatusUpdateIsConsistentAndDoesNotLoop() { + extension.create(testResource()); + + // the status is persisted via the own SSA status update + await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + var actual = extension.get(OwnSsaStatusUpdateCustomResource.class, RESOURCE_NAME); + assertThat(actual.getStatus()).isNotNull(); + assertThat(actual.getStatus().getValue()).isEqualTo(STATUS_VALUE); + }); + + // the own status write must be filtered: no reconciliation loop + await() + .during(Duration.ofSeconds(2)) + .atMost(Duration.ofSeconds(5)) + .untilAsserted( + () -> + assertThat(reconciler.numberOfExecutions.get()) + .as("own SSA status update must not trigger a reconciliation loop") + .isLessThanOrEqualTo(2)); + + var executionsBeforeExternalUpdate = reconciler.numberOfExecutions.get(); + + // an external party changes a label; this must still trigger a fresh reconciliation + var current = extension.get(OwnSsaStatusUpdateCustomResource.class, RESOURCE_NAME); + var labels = new HashMap(); + if (current.getMetadata().getLabels() != null) { + labels.putAll(current.getMetadata().getLabels()); + } + labels.put(EXTERNAL_LABEL_KEY, EXTERNAL_LABEL_VALUE); + current.getMetadata().setLabels(labels); + extension.replace(current); + + await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + assertThat(reconciler.numberOfExecutions.get()) + .isGreaterThan(executionsBeforeExternalUpdate); + assertThat(reconciler.externalLabelSeenInLaterReconciliation.get()) + .as("a later reconciliation must observe the externally-applied label") + .isTrue(); + }); + } + + OwnSsaStatusUpdateCustomResource testResource() { + var r = new OwnSsaStatusUpdateCustomResource(); + r.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build()); + return r; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownssastatusupdate/OwnSsaStatusUpdateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownssastatusupdate/OwnSsaStatusUpdateReconciler.java new file mode 100644 index 0000000000..472aeb47d6 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownssastatusupdate/OwnSsaStatusUpdateReconciler.java @@ -0,0 +1,66 @@ +/* + * 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.baseapi.readcacheafterwrite.ownssastatusupdate; + +import java.util.concurrent.atomic.AtomicBoolean; +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; + +/** + * Updates its own status via {@link + * io.javaoperatorsdk.operator.api.reconciler.ResourceOperations#serverSideApplyPrimaryStatus( + * io.fabric8.kubernetes.api.model.HasMetadata)} instead of returning an {@link UpdateControl}. The + * status is server-side applied through the controller's own event source, so the resulting own + * event must be filtered and must not trigger a reconciliation loop. A later external update (a + * label change) must still be observed by a fresh reconciliation. + */ +@ControllerConfiguration(generationAwareEventProcessing = false) +public class OwnSsaStatusUpdateReconciler implements Reconciler { + + static final String STATUS_VALUE = "ready"; + static final String EXTERNAL_LABEL_KEY = "externally-set"; + static final String EXTERNAL_LABEL_VALUE = "yes"; + + final AtomicInteger numberOfExecutions = new AtomicInteger(); + final AtomicBoolean externalLabelSeenInLaterReconciliation = new AtomicBoolean(); + + @Override + public UpdateControl reconcile( + OwnSsaStatusUpdateCustomResource resource, + Context context) { + numberOfExecutions.incrementAndGet(); + + var labels = resource.getMetadata().getLabels(); + if (labels != null && EXTERNAL_LABEL_VALUE.equals(labels.get(EXTERNAL_LABEL_KEY))) { + externalLabelSeenInLaterReconciliation.set(true); + } + + // Only apply the status when it is not already set - the SSA status matcher makes repeated + // applies no-ops anyway, but this keeps the intent explicit and the reconciliation idempotent. + if (resource.getStatus() == null || !STATUS_VALUE.equals(resource.getStatus().getValue())) { + resource.setStatus(new OwnSsaStatusUpdateStatus().setValue(STATUS_VALUE)); + // SSA does not use optimistic locking + resource.getMetadata().setResourceVersion(null); + context.resourceOperations().serverSideApplyPrimaryStatus(resource); + } + + return UpdateControl.noUpdate(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownssastatusupdate/OwnSsaStatusUpdateStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownssastatusupdate/OwnSsaStatusUpdateStatus.java new file mode 100644 index 0000000000..48e2e1d73c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownssastatusupdate/OwnSsaStatusUpdateStatus.java @@ -0,0 +1,30 @@ +/* + * 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.baseapi.readcacheafterwrite.ownssastatusupdate; + +public class OwnSsaStatusUpdateStatus { + + private String value; + + public String getValue() { + return value; + } + + public OwnSsaStatusUpdateStatus setValue(String value) { + this.value = value; + return this; + } +} From c5ac59b6aa06d2f5a8615b9fbfba22ece391f0d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 14 Jul 2026 14:23:49 +0200 Subject: [PATCH 24/35] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../en/docs/migration/v5-5-migration.md | 69 ++++++++ .../api/reconciler/ResourceOperations.java | 155 ++++++++++++++++-- ...SpecChangeDuringStatusPatchReconciler.java | 9 +- 3 files changed, 213 insertions(+), 20 deletions(-) create mode 100644 docs/content/en/docs/migration/v5-5-migration.md diff --git a/docs/content/en/docs/migration/v5-5-migration.md b/docs/content/en/docs/migration/v5-5-migration.md new file mode 100644 index 0000000000..0d9194e7e3 --- /dev/null +++ b/docs/content/en/docs/migration/v5-5-migration.md @@ -0,0 +1,69 @@ +--- +title: Migrating from v5.4 to v5.5 +description: Migrating from v5.4 to v5.5 +--- + +## No breaking API changes + +v5.5 does **not** contain breaking API changes: existing code compiles and continues to work without +modification. There is, however, one **behavioral** change around own-event filtering that you should +be aware of (see below). + +## `UpdateControl` no longer filters own events by default + +In previous versions, updating the primary resource (or its status) by returning an `UpdateControl` +from the reconciler would filter out the event caused by that own update, so the write did not +trigger an additional reconciliation. Unfortunately, in some edge cases this could lead an +incorrect behavior, thus filtering out events which should be propagated. + +More precisely in case where for example the spec part of the primary resource was updated, while +controller patched the status, but that patch status was a no-op operation. Note that +these no-op operations are causing issue, which should not be done in first place. +From 5.5 we provide methods in `ResourceOperations` which allow only operations which are +correct. + +From v5.5, `UpdateControl` **no longer filters these own events by default**. Returning an +`UpdateControl` still updates the resource and keeps the cache read-after-write consistent, but the +resulting update event is now delivered like any other event, which may cause an additional +reconciliation. + +This is safe (reconciliations are expected to be idempotent), but if you relied on the previous +filtering — for example to avoid an extra reconciliation after a status update — use +[`ResourceOperations`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java) +directly, which does filter own events. + +### Using `ResourceOperations` instead + +`ResourceOperations` is available from the reconciliation `Context` via +`context.resourceOperations()`. Instead of returning an `UpdateControl`, perform the update through +it and return `UpdateControl.noUpdate()`: + +```java +// before (v5.4): the own status update event was filtered +@Override +public UpdateControl reconcile(MyResource resource, Context context) { + resource.setStatus(new MyStatus().setReady(true)); + return UpdateControl.patchStatus(resource); +} +``` + +```java +// after (v5.5): filter the own event explicitly via ResourceOperations +@Override +public UpdateControl reconcile(MyResource resource, Context context) { + resource.setStatus(new MyStatus().setReady(true)); + context.resourceOperations().serverSideApplyPrimaryStatus(resource); + return UpdateControl.noUpdate(); +} +``` + +`ResourceOperations` covers every update/patch strategy (server-side apply, update, JSON Patch, JSON +Merge Patch) for both the whole resource and the status subresource, as well as their primary +variants. By default these operations match the desired state against the actual (cached) state +before writing and filter the own event, so they only issue a request to the Kubernetes API server +when something actually changed. + +> **Note**: Safe own-event filtering requires either a matcher (used by default) or the update to be +> done with optimistic locking. See the `ResourceOperations` and `ResourceOperations.Options` +> documentation for the available modes, the correctness requirements, and the caveats of the +> default matchers. diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 2fae340cbb..50c03e0890 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -39,12 +39,67 @@ import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getVersion; /** - * Provides useful operations to manipulate resources (server-side apply, patch, etc.) in an - * idiomatic way, in particular to make sure that the latest version of the resource is present in - * the caches for the next reconciliation. In other words, it provides read-cache-after-write - * consistency. + * Provides various, useful operations to manipulate resources (server-side apply, patch, etc.) in + * an idiomatic ways. Provides improved update/patch/create operations to make sure that the latest + * version of the resource is present in the caches for the next reconciliation, and filter own + * update events. You can still use kubernetes client directly, these methods are however useful to + * achieve better efficiency. * - * @param

the resource type on which this object operates + *

Every update/patch/create method comes in two flavors: a default one, and one that takes an + * {@link Options} argument to control how the resulting own event is handled. The behavior is + * selected through {@link Mode}: + * + *

    + *
  • {@link Options#filterIfOptimisticLocking()} ({@link Mode#FILTER_IF_OPTIMISTIC_LOCKING}) - + * the own event is filtered only when the write uses optimistic locking (i.e. the resource + * version is set on the resource being written); otherwise the response is only cached. This + * is the safe default for plain update/patch operations. + *
  • {@link Options#matchAndFilter(Matcher)} / {@link + * Options#matchAndFilterWithDefaultMatcher(io.javaoperatorsdk.operator.api.reconciler.matcher.UpdateType)} + * ({@link Mode#FILTER_IF_NOT_MATCHING}) - before writing, the desired state is compared to + * the actual (cached) state using the provided {@link Matcher}; if they already match the + * write is skipped, otherwise it is performed and the own event is filtered. + *
  • {@link Options#cacheOnly()} ({@link Mode#CACHE_ONLY}) - the response is only put into the + * cache (read-cache-after-write consistency) and no own-event filtering is done. + *
  • {@link Options#forceFilterEvents()} ({@link Mode#FORCE_FILTER}) - the own event is always + * filtered, regardless of optimistic locking. Use only when correctness is otherwise + * guaranteed (see the note below). This is mostly for internal usage, like in {@link + * io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource} + * where we match the resource explicitly before update. + *
+ * + *

Correctness of event filtering. Filtering an own update event is only safe if + * the framework can tell an own write apart from a concurrent third-party write. This requires + * either: + * + *

    + *
  • a {@link Matcher} to be provided (so a filtered event can be confirmed to match the desired + * state we just wrote), or + *
  • the update to be done using optimistic locking (a resource version set on the + * written resource), so a conflicting concurrent change is rejected by the API server rather + * than silently swallowed. + *
+ * + *

If neither holds - for example {@link Options#forceFilterEvents()} on a write without + * optimistic locking and without a matcher - a concurrent external update happening within the + * filtering window may be filtered out and thus missed until the next resync. Prefer providing a + * matcher or using optimistic locking whenever own-event filtering is desired. + * + *

Default matchers. For the matching modes a default {@link Matcher} is + * provided for every {@link io.javaoperatorsdk.operator.api.reconciler.matcher.UpdateType} (see + * {@link + * Options#matchAndFilterWithDefaultMatcher(io.javaoperatorsdk.operator.api.reconciler.matcher.UpdateType)}), + * so matching works out of the box. Note however that these default matchers are heuristics and may + * have issues in some edge cases, so a workflow relying on them should be tested against the + * concrete resources it manages. When a default matcher does not fit, provide your own via {@link + * Options#matchAndFilter(Matcher)}. + * + *

Despite that caveat, matching is generally the most efficient way to handle updates: it covers + * full event filtering and only performs a write when the actual state actually differs + * from the desired one, thus also reducing the number of requests made against the Kubernetes API + * server. + * + * @param

the primary resource type on which this object operates */ public class ResourceOperations

{ @@ -696,14 +751,12 @@ public R resourcePatch(R resource, UnaryOperator upda return resourcePatch(resource, updateOperation, Options.filterIfOptimisticLocking()); } - @Experimental(API_MIGHT_CHANGE) @SuppressWarnings({"rawtypes", "unchecked"}) private R resourcePatch( R desired, UnaryOperator updateOperation, Options options) { return resourcePatch(desired, null, updateOperation, options); } - @Experimental(API_MIGHT_CHANGE) @SuppressWarnings({"rawtypes", "unchecked"}) private R resourcePatch( R desired, R actual, UnaryOperator updateOperation, Options options) { @@ -731,12 +784,17 @@ private R resourcePatch( } } + /** + * This method is public to ensure backward compatibility, we will make it private in next major + * release. + */ + @Deprecated(forRemoval = true) public R resourcePatch( R desired, UnaryOperator updateOperation, ManagedInformerEventSource ies) { return resourcePatch(desired, updateOperation, ies, Options.filterIfOptimisticLocking()); } - public R resourcePatch( + private R resourcePatch( R desired, UnaryOperator updateOperation, ManagedInformerEventSource ies, @@ -745,7 +803,8 @@ public R resourcePatch( return resourcePatch(desired, null, updateOperation, ies, options); } - public R resourcePatch( + // visible for testing + R resourcePatch( R desiredResource, R actualResource, UnaryOperator updateOperation, @@ -758,7 +817,6 @@ public R resourcePatch( if (actualResource == null) { actualResource = ies.get(ResourceID.fromResource(desiredResource)).orElse(null); } - // todo describe might require optimistic locking if (actualResource != null) { matches = matcher.matches(desiredResource, actualResource, context); } @@ -769,8 +827,8 @@ public R resourcePatch( if (matches) { return actualResource; } + // this is to cover special case for jsonPatch were we should use actual resource as base var targetBaseResource = desiredResource != null ? desiredResource : actualResource; - boolean optimisticLocking = targetBaseResource.getMetadata().getResourceVersion() != null; if (options.getMode() == Mode.CACHE_ONLY @@ -968,6 +1026,28 @@ public P addFinalizerWithSSA(String finalizerName) { } } + /** + * Controls how an update/patch/create operation of {@link ResourceOperations} handles the own + * event resulting from the write. This is the entry point users interact with to tune caching and + * event filtering; instances are created through the static factory methods rather than a + * constructor, and each maps to a {@link Mode}. + * + *

See the {@link ResourceOperations} class documentation for a full description of the + * available strategies, their correctness requirements (a {@link Matcher} or optimistic locking + * is needed for safe own-event filtering), and the trade-offs of the default matchers. + * + *

    + *
  • {@link #filterIfOptimisticLocking()} - filter the own event only when the write uses + * optimistic locking, otherwise only cache the response. + *
  • {@link #matchAndFilter(Matcher)} / {@link #matchAndFilterWithDefaultMatcher(UpdateType)} + * - skip the write when the desired state already matches the actual state, otherwise write + * and filter the own event. + *
  • {@link #cacheOnly()} / {@link #cacheOnly(Matcher)} - only cache the response, no + * own-event filtering. + *
  • {@link #forceFilterEvents()} - always filter the own event (mostly for internal usage). + * Mostly for internal usage, if we are sure that the resource is matched before. + *
+ */ @Experimental(API_MIGHT_CHANGE) public static class Options { @@ -977,25 +1057,53 @@ public static class Options { new Options(Mode.FILTER_IF_OPTIMISTIC_LOCKING, null); private final Mode mode; - private Matcher matcher; + private final Matcher matcher; private Options(Mode mode, Matcher matcher) { this.mode = mode; this.matcher = matcher; } + /** + * Always filters the own event resulting from the write, regardless of optimistic locking. Safe + * only when correctness is otherwise guaranteed (see {@link ResourceOperations}); mostly for + * internal usage. + * + * @return options that always filter the own event + */ public static Options forceFilterEvents() { return ALWAYS_FILTER; } + /** + * Only caches the response of the write for read-cache-after-write consistency, without doing + * any own-event filtering. + * + * @return options that only cache the response + */ public static Options cacheOnly() { return ONLY_CACHE; } + /** + * Like {@link #cacheOnly()} but additionally skips the write when the desired state already + * matches the actual (cached) state according to the given {@link Matcher}. + * + * @param matcher the matcher used to decide whether the actual state already matches the + * desired + * @return options that cache only, skipping the write when already matching + */ public static Options cacheOnly(Matcher matcher) { return new Options(Mode.CACHE_ONLY, matcher); } + /** + * Filters the own event only when the write uses optimistic locking (a resource version is set + * on the written resource); otherwise only caches the response. This is the safe default for + * plain update/patch operations. + * + * @return options that filter the own event only when optimistic locking is used + */ public static Options filterIfOptimisticLocking() { return FILTER_IF_OPTIMISTIC_LOCKING; } @@ -1013,6 +1121,14 @@ public static Options matchAndFilter(Matcher matcher) { return new Options(Mode.FILTER_IF_NOT_MATCHING, matcher); } + /** + * Same as {@link #matchAndFilter(Matcher)} but uses the default {@link Matcher} registered for + * the given {@link UpdateType}. See the {@link ResourceOperations} class documentation for the + * caveats of the default matchers. + * + * @param updateType the update type whose default matcher should be used + * @return options that match using the default matcher and filter the own event + */ public static Options matchAndFilterWithDefaultMatcher(UpdateType updateType) { return new Options(Mode.FILTER_IF_NOT_MATCHING, updateType.getMatcher()); } @@ -1030,13 +1146,22 @@ public boolean requiresMatcher() { } } + /** + * The strategy used to decide how the own event resulting from a write is handled. This is a + * low-level enum; users should not reference it directly but select the desired behavior through + * the {@link Options} factory methods (e.g. {@link Options#filterIfOptimisticLocking()}, {@link + * Options#matchAndFilter(Matcher)}, {@link Options#cacheOnly()}, {@link + * Options#forceFilterEvents()}), which is why each constant links to its corresponding factory. + */ @Experimental(API_MIGHT_CHANGE) - /** */ - public enum Mode { + enum Mode { + /** See {@link Options#filterIfOptimisticLocking()}. */ FILTER_IF_OPTIMISTIC_LOCKING, + /** See {@link Options#matchAndFilter(Matcher)}. */ FILTER_IF_NOT_MATCHING, + /** See {@link Options#cacheOnly()}. */ CACHE_ONLY, - /** */ + /** See {@link Options#forceFilterEvents()}. */ FORCE_FILTER, } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchReconciler.java index 33d809046b..c639838f00 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchReconciler.java @@ -27,11 +27,10 @@ /** * On the first reconciliation the reconciler patches its own status, but keeps the event filtering - * window (opened by {@link - * io.javaoperatorsdk.operator.api.reconciler.ResourceOperations#resourcePatch}) open until the test - * signals that it has changed the spec on the cluster. This reproduces the race where a spec change - * lands while the controller's own status patch is in flight: the spec change event must still - * propagate as a fresh reconciliation, it must not be absorbed as if it were our own status update. + * window open until the test signals that it has changed the spec on the cluster. This reproduces + * the race where a spec change lands while the controller's own status patch is in flight: the spec + * change event must still propagate as a fresh reconciliation, it must not be absorbed as if it + * were our own status update. */ @ControllerConfiguration public class SpecChangeDuringStatusPatchReconciler From cb7bb01dc0af085690f7ad153ba00f82fb797859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 14 Jul 2026 14:45:56 +0200 Subject: [PATCH 25/35] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .github/workflows/integration-tests.yml | 2 +- .../en/docs/documentation/reconciler.md | 45 +- .../api/reconciler/ResourceOperations.java | 628 +++++++++++------- .../matcher/JsonMergePatchMatcher.java | 3 +- .../matcher/JsonMergePatchStatusMatcher.java | 3 +- .../matcher/JsonPatchMacher.java | 3 +- .../matcher/JsonPatchStatusMacher.java | 3 +- .../matcher/MatcherUtils.java | 3 +- .../matcher/SSAMatcher.java | 3 +- .../matcher/SSAStatusMatcher.java | 3 +- .../matcher/UpdateMatcher.java | 3 +- .../matcher/UpdateStatusMatcher.java | 3 +- .../matcher/UpdateType.java | 4 +- .../matcher/PatchMatchersTest.java | 2 +- .../matcher/StatusMatchersTest.java | 2 +- 15 files changed, 453 insertions(+), 257 deletions(-) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/{api/reconciler => processing}/matcher/JsonMergePatchMatcher.java (90%) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/{api/reconciler => processing}/matcher/JsonMergePatchStatusMatcher.java (91%) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/{api/reconciler => processing}/matcher/JsonPatchMacher.java (90%) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/{api/reconciler => processing}/matcher/JsonPatchStatusMacher.java (90%) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/{api/reconciler => processing}/matcher/MatcherUtils.java (96%) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/{api/reconciler => processing}/matcher/SSAMatcher.java (90%) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/{api/reconciler => processing}/matcher/SSAStatusMatcher.java (91%) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/{api/reconciler => processing}/matcher/UpdateMatcher.java (90%) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/{api/reconciler => processing}/matcher/UpdateStatusMatcher.java (91%) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/{api/reconciler => processing}/matcher/UpdateType.java (90%) rename operator-framework-core/src/test/java/io/javaoperatorsdk/operator/{api/reconciler => processing}/matcher/PatchMatchersTest.java (99%) rename operator-framework-core/src/test/java/io/javaoperatorsdk/operator/{api/reconciler => processing}/matcher/StatusMatchersTest.java (98%) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 385c5b67da..6264ba3aa2 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -27,7 +27,7 @@ jobs: name: Integration tests (${{ inputs.java-version }}, ${{ inputs.kube-version }}, ${{ inputs.http-client }}) runs-on: ubuntu-latest continue-on-error: ${{ inputs.experimental }} - timeout-minutes: 40 + timeout-minutes: 120 steps: - uses: actions/checkout@v7 with: diff --git a/docs/content/en/docs/documentation/reconciler.md b/docs/content/en/docs/documentation/reconciler.md index 7b24c23e3b..6151cdcb08 100644 --- a/docs/content/en/docs/documentation/reconciler.md +++ b/docs/content/en/docs/documentation/reconciler.md @@ -192,6 +192,37 @@ supports stronger guarantees, both for primary and secondary resources. If this they would otherwise look like own echoes, since the relist may have hidden events. +#### Requesting event filtering and its correctness requirements + +Own-event filtering is only safe if the framework can tell an own write apart from a concurrent +third-party write. This requires **either**: + +- a *matcher* to be provided (so a filtered event can be confirmed to reflect the desired state we + just wrote), **or** +- the update to be done using *optimistic locking* (a resource version set on the written resource), + so a conflicting concurrent change is rejected by the API server rather than silently swallowed. + +`ResourceOperations` methods accept an `Options` argument to select the behavior; each maps to a +`Mode`: + +- `Options.matchAndFilter(matcher)` / `Options.matchAndFilterWithDefaultMatcher(updateType)` — compare + the desired state to the actual (cached) state; if they already match, skip the write entirely, + otherwise write and filter the own event. This is the **default** for the `ResourceOperations` + update/patch methods, and generally the most efficient option: it filters the own event *and* + avoids a request to the API server when nothing changed. Default matchers are provided for every + operation type, but they are heuristics — a workflow relying on them should be tested against the + concrete resources it manages, or a custom matcher supplied. +- `Options.filterIfOptimisticLocking()` — filter the own event only when the write uses optimistic + locking, otherwise just cache the response. +- `Options.cacheOnly()` — only cache the response (read-cache-after-write consistency), no own-event + filtering. +- `Options.forceFilterEvents()` — always filter, regardless of optimistic locking. Only safe when + correctness is otherwise guaranteed (mostly for internal usage); a concurrent external update in + the filter window may otherwise be missed until the next resync. + +See the [`ResourceOperations`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java) +and `ResourceOperations.Options` documentation for details. + In order to benefit from these stronger guarantees, use [`ResourceOperations`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java) from the context of the reconciliation: @@ -208,20 +239,26 @@ public UpdateControl reconcile(WebPage webPage, Context contex var upToDateResource = context.getSecondaryResource(ConfigMap.class); makeStatusChanges(webPage); - - // built in update methods by default use this feature + // patches the status and caches the response (does not filter the own event by default, see below) return UpdateControl.patchStatus(webPage); } ``` -`UpdateControl` and `ErrorStatusUpdateControl` by default use this functionality, but you can also update your primary resource at any time during the reconciliation using `ResourceOperations`: +{{% alert title="UpdateControl and event filtering" %}} +Since v5.5, returning an `UpdateControl` (or `ErrorStatusUpdateControl`) updates the resource and +keeps the cache read-after-write consistent, but by default it **no longer filters the own event** — +the resulting update may cause an additional (idempotent) reconciliation. To also filter the own +event, perform the update through `ResourceOperations` instead and return `UpdateControl.noUpdate()`. +{{% /alert %}} + +You can update your primary resource at any time during the reconciliation using `ResourceOperations`: ```java public UpdateControl reconcile(WebPage webPage, Context context) { makeStatusChanges(webPage); - // this is equivalent to UpdateControl.patchStatus(webpage) + // updates the status, filters the own event and skips the write if nothing changed context.resourceOperations().serverSideApplyPrimaryStatus(webPage); return UpdateControl.noUpdate(); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 50c03e0890..43f7a8f209 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -29,10 +29,10 @@ import io.fabric8.kubernetes.client.dsl.base.PatchType; import io.javaoperatorsdk.operator.OperatorException; import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher; -import io.javaoperatorsdk.operator.api.reconciler.matcher.UpdateType; import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; +import io.javaoperatorsdk.operator.processing.matcher.UpdateType; import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE; import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getUID; @@ -55,7 +55,7 @@ * version is set on the resource being written); otherwise the response is only cached. This * is the safe default for plain update/patch operations. *
  • {@link Options#matchAndFilter(Matcher)} / {@link - * Options#matchAndFilterWithDefaultMatcher(io.javaoperatorsdk.operator.api.reconciler.matcher.UpdateType)} + * Options#matchAndFilterWithDefaultMatcher(io.javaoperatorsdk.operator.processing.matcher.UpdateType)} * ({@link Mode#FILTER_IF_NOT_MATCHING}) - before writing, the desired state is compared to * the actual (cached) state using the provided {@link Matcher}; if they already match the * write is skipped, otherwise it is performed and the own event is filtered. @@ -86,9 +86,8 @@ * matcher or using optimistic locking whenever own-event filtering is desired. * *

    Default matchers. For the matching modes a default {@link Matcher} is - * provided for every {@link io.javaoperatorsdk.operator.api.reconciler.matcher.UpdateType} (see - * {@link - * Options#matchAndFilterWithDefaultMatcher(io.javaoperatorsdk.operator.api.reconciler.matcher.UpdateType)}), + * provided for every {@link io.javaoperatorsdk.operator.processing.matcher.UpdateType} (see {@link + * Options#matchAndFilterWithDefaultMatcher(io.javaoperatorsdk.operator.processing.matcher.UpdateType)}), * so matching works out of the box. Note however that these default matchers are heuristics and may * have issues in some edge cases, so a workflow relying on them should be tested against the * concrete resources it manages. When a default matcher does not fit, provide your own via {@link @@ -114,22 +113,33 @@ public ResourceOperations(Context

    context) { } /** - * Updates the resource and caches the response if needed, thus making sure that next - * reconciliation will see to updated resource - or more recent one if additional update happened - * after this update; In addition to that it filters out the event from the update, so - * reconciliation is not triggered by own update. + * Server-Side Applies the resource, then caches the response so the next reconciliation observes + * it (read-cache-after-write consistency). * - *

    You are free to control the optimistic locking by setting the resource version in resource - * metadata. In case of SSA we advise not to do updates with optimistic locking. + *

    Uses the {@link UpdateType#SSA} default {@link Matcher}: the apply is skipped when the + * actual (cached) state already matches the desired one, otherwise it is performed and the + * resulting own event is filtered so it does not trigger a reconciliation. Use {@link + * #serverSideApply( HasMetadata, Options)} to change this behavior. SSA does not require + * optimistic locking. * - * @param resource fresh resource for server side apply - * @return updated resource - * @param resource type + * @param resource the desired resource to server-side apply + * @param the resource type + * @return the applied resource as returned by the API server */ public R serverSideApply(R resource) { return serverSideApply(resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.SSA)); } + /** + * Server-Side Applies the resource, controlling caching and own-event handling through the given + * {@link Options}. + * + * @param resource the desired resource to server-side apply + * @param options controls caching and own-event filtering; see {@link Options} and the class + * documentation + * @param the resource type + * @return the applied resource as returned by the API server + */ @Experimental(API_MIGHT_CHANGE) public R serverSideApply(R resource, Options options) { return resourcePatch( @@ -147,6 +157,16 @@ public R serverSideApply(R resource, Options options) { options); } + /** + * Server-Side Applies the resource, caching and filtering the own event through the given {@link + * InformerEventSource} (instead of the controller's own event source). Uses the {@link + * UpdateType#SSA} default {@link Matcher}. + * + * @param resource the desired resource to server-side apply + * @param informerEventSource the event source used for caching and own-event filtering + * @param the resource type + * @return the applied resource as returned by the API server + */ public R serverSideApply( R resource, InformerEventSource informerEventSource) { return serverSideApply( @@ -154,18 +174,15 @@ public R serverSideApply( } /** - * Updates the resource and caches the response if needed, thus making sure that next - * reconciliation will see to updated resource - or more recent one if additional update happened - * after this update; In addition to that it filters out the event from the update, so - * reconciliation is not triggered by own update. - * - *

    You are free to control the optimistic locking by setting the resource version in resource - * metadata. In case of SSA we advise not to do updates with optimistic locking. + * Server-Side Applies the resource, caching and filtering the own event through the given {@link + * InformerEventSource} and using the given {@link Options}. When {@code informerEventSource} is + * {@code null} this falls back to {@link #serverSideApply(HasMetadata)}. * - * @param resource fresh resource for server side apply - * @return updated resource - * @param informerEventSource InformerEventSource to use for resource caching and filtering - * @param resource type + * @param resource the desired resource to server-side apply + * @param informerEventSource the event source used for caching and own-event filtering + * @param options controls caching and own-event filtering; see {@link Options} + * @param the resource type + * @return the applied resource as returned by the API server */ @Experimental(API_MIGHT_CHANGE) public R serverSideApply( @@ -190,19 +207,13 @@ public R serverSideApply( } /** - * Server-Side Apply the resource status subresource. + * Server-Side Applies the {@code status} subresource, controlling caching and own-event handling + * through the given {@link Options}. * - *

    Updates the resource and caches the response if needed, thus making sure that next - * reconciliation will see to updated resource - or more recent one if additional update happened - * after this update; In addition to that it filters out the event from this update, so - * reconciliation is not triggered by own update. - * - *

    You are free to control the optimistic locking by setting the resource version in resource - * metadata. In case of SSA we advise not to do updates with optimistic locking. - * - * @param resource fresh resource for server side apply - * @return updated resource - * @param resource type + * @param resource the desired resource (with the status to apply) + * @param options controls caching and own-event filtering; see {@link Options} + * @param the resource type + * @return the applied resource as returned by the API server */ public R serverSideApplyStatus(R resource, Options options) { return resourcePatch( @@ -221,29 +232,38 @@ public R serverSideApplyStatus(R resource, Options optio options); } + /** + * Server-Side Applies the {@code status} subresource, caching the response and filtering the own + * event. Uses the {@link UpdateType#SSA_STATUS} default {@link Matcher}. + * + * @param resource the desired resource (with the status to apply) + * @param the resource type + * @return the applied resource as returned by the API server + */ public R serverSideApplyStatus(R resource) { return serverSideApplyStatus( resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.SSA_STATUS)); } + /** + * Server-Side Applies the primary resource, caching and filtering the own event through the + * controller's own event source. Uses the {@link UpdateType#SSA} default {@link Matcher}. + * + * @param resource the desired primary resource to server-side apply + * @return the applied resource as returned by the API server + */ public P serverSideApplyPrimary(P resource) { return serverSideApplyPrimary( resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.SSA)); } /** - * Server-Side Apply the primary resource. - * - *

    Updates the resource and caches the response if needed, thus making sure that next - * reconciliation will see to updated resource - or more recent one if additional update happened - * after this update; In addition to that it filters out the event from this update, so - * reconciliation is not triggered by own update. + * Server-Side Applies the primary resource, caching and filtering the own event through the + * controller's own event source and using the given {@link Options}. * - *

    You are free to control the optimistic locking by setting the resource version in resource - * metadata. In case of SSA we advise not to do updates with optimistic locking. - * - * @param resource primary resource for server side apply - * @return updated resource + * @param resource the desired primary resource to server-side apply + * @param options controls caching and own-event filtering; see {@link Options} + * @return the applied resource as returned by the API server */ public P serverSideApplyPrimary(P resource, Options options) { return resourcePatch( @@ -263,24 +283,26 @@ public P serverSideApplyPrimary(P resource, Options options) { } /** - * Server-Side Apply the primary resource status subresource. - * - *

    Updates the resource and caches the response if needed, thus making sure that next - * reconciliation will see to updated resource - or more recent one if additional update happened - * after this update; In addition to that it filters out the event from this update, so - * reconciliation is not triggered by own update. + * Server-Side Applies the primary resource {@code status} subresource, caching and filtering the + * own event through the controller's own event source. Uses the {@link UpdateType#SSA_STATUS} + * default {@link Matcher}. * - *

    You are free to control the optimistic locking by setting the resource version in resource - * metadata. In case of SSA we advise not to do updates with optimistic locking. - * - * @param desired primary resource for server side apply - * @return updated resource + * @param desired the desired primary resource (with the status to apply) + * @return the applied resource as returned by the API server */ public P serverSideApplyPrimaryStatus(P desired) { return serverSideApplyPrimaryStatus( desired, Options.matchAndFilterWithDefaultMatcher(UpdateType.SSA_STATUS)); } + /** + * Server-Side Applies the primary resource {@code status} subresource, caching and filtering the + * own event through the controller's own event source and using the given {@link Options}. + * + * @param desired the desired primary resource (with the status to apply) + * @param options controls caching and own-event filtering; see {@link Options} + * @return the applied resource as returned by the API server + */ public P serverSideApplyPrimaryStatus(P desired, Options options) { return resourcePatch( desired, @@ -299,28 +321,45 @@ public P serverSideApplyPrimaryStatus(P desired, Options options) { options); } + /** + * Updates (HTTP PUT) the resource, then caches the response so the next reconciliation observes + * it (read-cache-after-write consistency). + * + *

    Uses the {@link UpdateType#UPDATE} default {@link Matcher}: the update is skipped when the + * actual (cached) state already matches the desired one, otherwise it is performed and the + * resulting own event is filtered. Use {@link #update(HasMetadata, Options)} to change this. + * + * @param resource the desired resource to update + * @param the resource type + * @return the updated resource as returned by the API server + */ public R update(R resource) { return update(resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.UPDATE)); } /** - * Updates the resource and caches the response if needed, thus making sure that next - * reconciliation will see to updated resource - or more recent one if additional update happened - * after this update; In addition to that it filters out the event from this update, so - * reconciliation is not triggered by own update. - * - *

    You are free to control the optimistic locking by setting the resource version in resource - * metadata. + * Updates (HTTP PUT) the resource, controlling caching and own-event handling through the given + * {@link Options}. * - * @param desired resource to update - * @return updated resource - * @param resource type + * @param desired the desired resource to update + * @param options controls caching and own-event filtering; see {@link Options} + * @param the resource type + * @return the updated resource as returned by the API server */ @Experimental(API_MIGHT_CHANGE) public R update(R desired, Options options) { return resourcePatch(desired, r -> context.getClient().resource(r).update(), options); } + /** + * Updates (HTTP PUT) the resource, caching and filtering the own event through the given {@link + * InformerEventSource}. Uses the {@link UpdateType#UPDATE} default {@link Matcher}. + * + * @param resource the desired resource to update + * @param informerEventSource the event source used for caching and own-event filtering + * @param the resource type + * @return the updated resource as returned by the API server + */ public R update( R resource, InformerEventSource informerEventSource) { return update( @@ -328,18 +367,15 @@ public R update( } /** - * Updates the resource and caches the response if needed, thus making sure that next - * reconciliation will see to updated resource - or more recent one if additional update happened - * after this update; In addition to that it filters out the event from this update, so - * reconciliation is not triggered by own update. + * Updates (HTTP PUT) the resource, caching and filtering the own event through the given {@link + * InformerEventSource} and using the given {@link Options}. When {@code informerEventSource} is + * {@code null} this falls back to {@link #update(HasMetadata)}. * - *

    You are free to control the optimistic locking by setting the resource version in resource - * metadata. - * - * @param resource resource to update - * @return updated resource - * @param informerEventSource InformerEventSource to use for resource caching and filtering - * @param resource type + * @param resource the desired resource to update + * @param informerEventSource the event source used for caching and own-event filtering + * @param options controls caching and own-event filtering; see {@link Options} + * @param the resource type + * @return the updated resource as returned by the API server */ @Experimental(API_MIGHT_CHANGE) public R update( @@ -352,23 +388,29 @@ public R update( } /** - * Creates the resource and caches the response if needed, thus making sure that next - * reconciliation will see to updated resource - or more recent one if additional update happened - * after this update; In addition to that it filters out the event from this update, so - * reconciliation is not triggered by own update. - * - *

    You are free to control the optimistic locking by setting the resource version in resource - * metadata. + * Creates the resource, then caches the response so the next reconciliation observes it + * (read-cache-after-write consistency). The resulting own event is filtered. * - * @param resource resource to update - * @return updated resource - * @param resource type + * @param resource the resource to create + * @param the resource type + * @return the created resource as returned by the API server */ public R create(R resource) { // it is safe to do event filtering for create since check if the resource already exists. return create(resource, Options.forceFilterEvents()); } + /** + * Creates the resource, controlling caching and own-event handling through the given {@link + * Options}. A matcher is not supported for create (there is nothing to match against) and passing + * one throws {@link IllegalArgumentException}. + * + * @param resource the resource to create + * @param options controls caching and own-event filtering; see {@link Options} + * @param the resource type + * @return the created resource as returned by the API server + * @throws IllegalArgumentException if the options carry a {@link Matcher} + */ public R create(R resource, Options options) { if (options.getMatcher().isPresent()) { throw new IllegalArgumentException( @@ -378,18 +420,15 @@ public R create(R resource, Options options) { } /** - * Creates the resource and caches the response if needed, thus making sure that next - * reconciliation will see to updated resource - or more recent one if additional update happened - * after this update; In addition to that it filters out the event from this update, so - * reconciliation is not triggered by own update. + * Creates the resource, caching and filtering the own event through the given {@link + * InformerEventSource} and using the given {@link Options}. When {@code informerEventSource} is + * {@code null} this falls back to {@link #create(HasMetadata)}. * - *

    You are free to control the optimistic locking by setting the resource version in resource - * metadata. - * - * @param resource resource to update - * @return updated resource - * @param informerEventSource InformerEventSource to use for resource caching and filtering - * @param resource type + * @param resource the resource to create + * @param informerEventSource the event source used for caching and own-event filtering + * @param options controls caching and own-event filtering; see {@link Options} + * @param the resource type + * @return the created resource as returned by the API server */ public R create( R resource, InformerEventSource informerEventSource, Options options) { @@ -402,47 +441,50 @@ public R create( } /** - * Updates the resource status subresource. - * - *

    Updates the resource and caches the response if needed, thus making sure that next - * reconciliation will see to updated resource - or more recent one if additional update happened - * after this update; In addition to that it filters out the event from this update, so - * reconciliation is not triggered by own update. + * Updates (HTTP PUT) the resource {@code status} subresource, caching the response and filtering + * the own event. Uses the {@link UpdateType#UPDATE_STATUS} default {@link Matcher}. * - *

    You are free to control the optimistic locking by setting the resource version in resource - * metadata. - * - * @param resource resource to update - * @return updated resource - * @param resource type + * @param resource the desired resource (with the status to update) + * @param the resource type + * @return the updated resource as returned by the API server */ public R updateStatus(R resource) { return updateStatus( resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.UPDATE_STATUS)); } + /** + * Updates (HTTP PUT) the resource {@code status} subresource, controlling caching and own-event + * handling through the given {@link Options}. + * + * @param resource the desired resource (with the status to update) + * @param options controls caching and own-event filtering; see {@link Options} + * @param the resource type + * @return the updated resource as returned by the API server + */ public R updateStatus(R resource, Options options) { return resourcePatch(resource, r -> context.getClient().resource(r).updateStatus(), options); } /** - * Updates the primary resource. - * - *

    Updates the resource and caches the response if needed, thus making sure that next - * reconciliation will see to updated resource - or more recent one if additional update happened - * after this update; In addition to that it filters out the event from this update, so - * reconciliation is not triggered by own update. + * Updates (HTTP PUT) the primary resource, caching and filtering the own event through the + * controller's own event source. Uses the {@link UpdateType#UPDATE} default {@link Matcher}. * - *

    You are free to control the optimistic locking by setting the resource version in resource - * metadata. - * - * @param desired primary resource to update - * @return updated resource + * @param desired the desired primary resource to update + * @return the updated resource as returned by the API server */ public P updatePrimary(P desired) { return updatePrimary(desired, Options.matchAndFilterWithDefaultMatcher(UpdateType.UPDATE)); } + /** + * Updates (HTTP PUT) the primary resource, caching and filtering the own event through the + * controller's own event source and using the given {@link Options}. + * + * @param desired the desired primary resource to update + * @param options controls caching and own-event filtering; see {@link Options} + * @return the updated resource as returned by the API server + */ public P updatePrimary(P desired, Options options) { return resourcePatch( desired, @@ -452,18 +494,12 @@ public P updatePrimary(P desired, Options options) { } /** - * Updates the primary resource status subresource. + * Updates (HTTP PUT) the primary resource {@code status} subresource, caching and filtering the + * own event through the controller's own event source. Uses the {@link UpdateType#UPDATE_STATUS} + * default {@link Matcher}. * - *

    Updates the resource and caches the response if needed, thus making sure that next - * reconciliation will see to updated resource - or more recent one if additional update happened - * after this update; In addition to that it filters out the event from this update, so - * reconciliation is not triggered by own update. - * - *

    You are free to control the optimistic locking by setting the resource version in resource - * metadata. - * - * @param desired primary resource to update - * @return updated resource + * @param desired the desired primary resource (with the status to update) + * @return the updated resource as returned by the API server */ public P updatePrimaryStatus(P desired) { return resourcePatch( @@ -474,19 +510,15 @@ public P updatePrimaryStatus(P desired) { } /** - * Applies a JSON Patch to the resource. The unaryOperator function is used to modify the - * resource, and the differences are sent as a JSON Patch to the Kubernetes API server. - * - *

    Updates the resource and caches the response if needed, thus making sure that next - * reconciliation will see to updated resource - or more recent one if additional update happened - * after this update; In addition to that it filters out the event from this update, so - * reconciliation is not triggered by own update. + * Applies a JSON Patch (RFC 6902) to the resource: the {@code unaryOperator} mutates the resource + * and the diff is sent as a patch. Caches the response and filters the own event using the {@link + * UpdateType#JSON_PATCH} default {@link Matcher}. Use {@link #jsonPatch(HasMetadata, + * UnaryOperator, Options)} to change this behavior. * - *

    You are free to control the optimistic locking by setting the resource version in resource - * metadata. - * - * @return updated resource - * @param resource type + * @param actualResource the current resource used as the base of the patch + * @param unaryOperator function that mutates the resource into its desired state + * @param the resource type + * @return the patched resource as returned by the API server */ public R jsonPatch(R actualResource, UnaryOperator unaryOperator) { return jsonPatch( @@ -495,6 +527,16 @@ public R jsonPatch(R actualResource, UnaryOperator un Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_PATCH)); } + /** + * Applies a JSON Patch (RFC 6902) to the resource, controlling caching and own-event handling + * through the given {@link Options}. + * + * @param actualResource the current resource used as the base of the patch + * @param unaryOperator function that mutates the resource into its desired state + * @param options controls caching and own-event filtering; see {@link Options} + * @param the resource type + * @return the patched resource as returned by the API server + */ public R jsonPatch( R actualResource, UnaryOperator unaryOperator, Options options) { return resourcePatch( @@ -503,6 +545,17 @@ public R jsonPatch( options); } + /** + * Applies a JSON Patch (RFC 6902) to the resource, caching and filtering the own event through + * the given {@link InformerEventSource} and using the given {@link Options}. + * + * @param actualResource the current resource used as the base of the patch + * @param unaryOperator function that mutates the resource into its desired state + * @param informerEventSource the event source used for caching and own-event filtering + * @param options controls caching and own-event filtering; see {@link Options} + * @param the resource type + * @return the patched resource as returned by the API server + */ public R jsonPatch( R actualResource, UnaryOperator unaryOperator, @@ -517,21 +570,15 @@ public R jsonPatch( } /** - * Applies a JSON Patch to the resource status subresource. The unaryOperator function is used to - * modify the resource status, and the differences are sent as a JSON Patch. - * - *

    Updates the resource and caches the response if needed, thus making sure that next - * reconciliation will see to updated resource - or more recent one if additional update happened - * after this update; In addition to that it filters out the event from this update, so - * reconciliation is not triggered by own update. + * Applies a JSON Patch (RFC 6902) to the resource {@code status} subresource: the {@code + * unaryOperator} mutates the resource status and the diff is sent as a patch. Caches the response + * and filters the own event using the {@link UpdateType#JSON_PATCH_STATUS} default {@link + * Matcher}. * - *

    You are free to control the optimistic locking by setting the resource version in resource - * metadata. - * - * @param actualResource resource to patch - * @param unaryOperator function to modify the resource status - * @return updated resource - * @param resource type + * @param actualResource the current resource used as the base of the patch + * @param unaryOperator function that mutates the resource status into its desired state + * @param the resource type + * @return the patched resource as returned by the API server */ public R jsonPatchStatus( R actualResource, UnaryOperator unaryOperator) { @@ -541,6 +588,16 @@ public R jsonPatchStatus( Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_PATCH_STATUS)); } + /** + * Applies a JSON Patch (RFC 6902) to the resource {@code status} subresource, controlling caching + * and own-event handling through the given {@link Options}. + * + * @param actualResource the current resource used as the base of the patch + * @param unaryOperator function that mutates the resource status into its desired state + * @param options controls caching and own-event filtering; see {@link Options} + * @param the resource type + * @return the patched resource as returned by the API server + */ public R jsonPatchStatus( R actualResource, UnaryOperator unaryOperator, Options options) { R desired = desiredForJsonPatch(actualResource, unaryOperator, options); @@ -551,6 +608,18 @@ public R jsonPatchStatus( options); } + /** + * Applies a JSON Patch (RFC 6902) to the resource {@code status} subresource, caching and + * filtering the own event through the given {@link InformerEventSource} and using the given + * {@link Options}. + * + * @param actualResource the current resource used as the base of the patch + * @param unaryOperator function that mutates the resource status into its desired state + * @param informerEventSource the event source used for caching and own-event filtering + * @param options controls caching and own-event filtering; see {@link Options} + * @param the resource type + * @return the patched resource as returned by the API server + */ public R jsonPatchStatus( R actualResource, UnaryOperator unaryOperator, @@ -566,19 +635,13 @@ public R jsonPatchStatus( } /** - * Applies a JSON Patch to the primary resource. - * - *

    Updates the resource and caches the response if needed, thus making sure that next - * reconciliation will see to updated resource - or more recent one if additional update happened - * after this update; In addition to that it filters out the event from this update, so - * reconciliation is not triggered by own update. - * - *

    You are free to control the optimistic locking by setting the resource version in resource - * metadata. + * Applies a JSON Patch (RFC 6902) to the primary resource, caching and filtering the own event + * through the controller's own event source. Uses the {@link UpdateType#JSON_PATCH} default + * {@link Matcher}. * - * @param actualResource primary resource to patch - * @param unaryOperator function to modify the resource - * @return updated resource + * @param actualResource the current primary resource used as the base of the patch + * @param unaryOperator function that mutates the resource into its desired state + * @return the patched resource as returned by the API server */ public P jsonPatchPrimary(P actualResource, UnaryOperator

    unaryOperator) { return jsonPatchPrimary( @@ -587,6 +650,15 @@ public P jsonPatchPrimary(P actualResource, UnaryOperator

    unaryOperator) { Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_PATCH)); } + /** + * Applies a JSON Patch (RFC 6902) to the primary resource, caching and filtering the own event + * through the controller's own event source and using the given {@link Options}. + * + * @param actualResource the current primary resource used as the base of the patch + * @param unaryOperator function that mutates the resource into its desired state + * @param options controls caching and own-event filtering; see {@link Options} + * @return the patched resource as returned by the API server + */ public P jsonPatchPrimary(P actualResource, UnaryOperator

    unaryOperator, Options options) { P desired = desiredForJsonPatch(actualResource, unaryOperator, options); return resourcePatch( @@ -598,19 +670,13 @@ public P jsonPatchPrimary(P actualResource, UnaryOperator

    unaryOperator, Opti } /** - * Applies a JSON Patch to the primary resource status subresource. + * Applies a JSON Patch (RFC 6902) to the primary resource {@code status} subresource, caching and + * filtering the own event through the controller's own event source. Uses the {@link + * UpdateType#JSON_PATCH_STATUS} default {@link Matcher}. * - *

    Updates the resource and caches the response if needed, thus making sure that next - * reconciliation will see to updated resource - or more recent one if additional update happened - * after this update; In addition to that it filters out the event from this update, so - * reconciliation is not triggered by own update. - * - *

    You are free to control the optimistic locking by setting the resource version in resource - * metadata. - * - * @param actualResource primary resource to patch - * @param unaryOperator function to modify the resource - * @return updated resource + * @param actualResource the current primary resource used as the base of the patch + * @param unaryOperator function that mutates the resource status into its desired state + * @return the patched resource as returned by the API server */ public P jsonPatchPrimaryStatus(P actualResource, UnaryOperator

    unaryOperator) { return jsonPatchPrimaryStatus( @@ -619,6 +685,16 @@ public P jsonPatchPrimaryStatus(P actualResource, UnaryOperator

    unaryOperator Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_PATCH_STATUS)); } + /** + * Applies a JSON Patch (RFC 6902) to the primary resource {@code status} subresource, caching and + * filtering the own event through the controller's own event source and using the given {@link + * Options}. + * + * @param actualResource the current primary resource used as the base of the patch + * @param unaryOperator function that mutates the resource status into its desired state + * @param options controls caching and own-event filtering; see {@link Options} + * @return the patched resource as returned by the API server + */ public P jsonPatchPrimaryStatus( P actualResource, UnaryOperator

    unaryOperator, Options options) { P desired = desiredForJsonPatch(actualResource, unaryOperator, options); @@ -631,30 +707,43 @@ public P jsonPatchPrimaryStatus( } /** - * Applies a JSON Merge Patch to the resource. JSON Merge Patch (RFC 7386) is a simpler patching - * strategy that merges the provided resource with the existing resource on the server. - * - *

    Updates the resource and caches the response if needed, thus making sure that next - * reconciliation will see to updated resource - or more recent one if additional update happened - * after this update; In addition to that it filters out the event from this update, so - * reconciliation is not triggered by own update. - * - *

    You are free to control the optimistic locking by setting the resource version in resource - * metadata. + * Applies a JSON Merge Patch (RFC 7386) to the resource, merging the provided resource with the + * one on the server. Caches the response and filters the own event using the {@link + * UpdateType#JSON_MERGE_PATCH} default {@link Matcher}. Use {@link #jsonMergePatch(HasMetadata, + * Options)} to change this behavior. * - * @param desired resource to patch - * @return updated resource - * @param resource type + * @param desired the desired resource to merge-patch + * @param the resource type + * @return the patched resource as returned by the API server */ public R jsonMergePatch(R desired) { return jsonMergePatch( desired, Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_MERGE_PATCH)); } + /** + * Applies a JSON Merge Patch (RFC 7386) to the resource, controlling caching and own-event + * handling through the given {@link Options}. + * + * @param desired the desired resource to merge-patch + * @param options controls caching and own-event filtering; see {@link Options} + * @param the resource type + * @return the patched resource as returned by the API server + */ public R jsonMergePatch(R desired, Options options) { return resourcePatch(desired, r -> context.getClient().resource(r).patch(), options); } + /** + * Applies a JSON Merge Patch (RFC 7386) to the resource, caching and filtering the own event + * through the given {@link InformerEventSource} and using the given {@link Options}. + * + * @param desired the desired resource to merge-patch + * @param informerEventSource the event source used for caching and own-event filtering + * @param options controls caching and own-event filtering; see {@link Options} + * @param the resource type + * @return the patched resource as returned by the API server + */ public R jsonMergePatch( R desired, InformerEventSource informerEventSource, Options options) { return resourcePatch( @@ -662,29 +751,43 @@ public R jsonMergePatch( } /** - * Applies a JSON Merge Patch to the resource status subresource. + * Applies a JSON Merge Patch (RFC 7386) to the resource {@code status} subresource, caching the + * response and filtering the own event. Uses the {@link UpdateType#JSON_MERGE_PATCH_STATUS} + * default {@link Matcher}. * - *

    Updates the resource and caches the response if needed, thus making sure that next - * reconciliation will see to updated resource - or more recent one if additional update happened - * after this update; In addition to that it filters out the event from this update, so - * reconciliation is not triggered by own update. - * - *

    You are free to control the optimistic locking by setting the resource version in resource - * metadata. - * - * @param resource resource to patch - * @return updated resource - * @param resource type + * @param resource the desired resource (with the status to merge-patch) + * @param the resource type + * @return the patched resource as returned by the API server */ public R jsonMergePatchStatus(R resource) { return jsonMergePatchStatus( resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_MERGE_PATCH_STATUS)); } + /** + * Applies a JSON Merge Patch (RFC 7386) to the resource {@code status} subresource, controlling + * caching and own-event handling through the given {@link Options}. + * + * @param resource the desired resource (with the status to merge-patch) + * @param options controls caching and own-event filtering; see {@link Options} + * @param the resource type + * @return the patched resource as returned by the API server + */ public R jsonMergePatchStatus(R resource, Options options) { return resourcePatch(resource, r -> context.getClient().resource(r).patchStatus(), options); } + /** + * Applies a JSON Merge Patch (RFC 7386) to the resource {@code status} subresource, caching and + * filtering the own event through the given {@link InformerEventSource} and using the given + * {@link Options}. + * + * @param resource the desired resource (with the status to merge-patch) + * @param informerEventSource the event source used for caching and own-event filtering + * @param options controls caching and own-event filtering; see {@link Options} + * @param the resource type + * @return the patched resource as returned by the API server + */ public R jsonMergePatchStatus( R resource, InformerEventSource informerEventSource, Options options) { return resourcePatch( @@ -692,25 +795,26 @@ public R jsonMergePatchStatus( } /** - * Applies a JSON Merge Patch to the primary resource. Caches the response using the controller's - * event source. - * - *

    Updates the resource and caches the response if needed, thus making sure that next - * reconciliation will see to updated resource - or more recent one if additional update happened - * after this update; In addition to that it filters out the event from this update, so - * reconciliation is not triggered by own update. + * Applies a JSON Merge Patch (RFC 7386) to the primary resource, caching and filtering the own + * event through the controller's own event source. Uses the {@link UpdateType#JSON_MERGE_PATCH} + * default {@link Matcher}. * - *

    You are free to control the optimistic locking by setting the resource version in resource - * metadata. - * - * @param resource primary resource to patch reconciliation - * @return updated resource + * @param resource the desired primary resource to merge-patch + * @return the patched resource as returned by the API server */ public P jsonMergePatchPrimary(P resource) { return jsonMergePatchPrimary( resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_MERGE_PATCH)); } + /** + * Applies a JSON Merge Patch (RFC 7386) to the primary resource, caching and filtering the own + * event through the controller's own event source and using the given {@link Options}. + * + * @param resource the desired primary resource to merge-patch + * @param options controls caching and own-event filtering; see {@link Options} + * @return the patched resource as returned by the API server + */ public P jsonMergePatchPrimary(P resource, Options options) { return resourcePatch( resource, @@ -720,25 +824,27 @@ public P jsonMergePatchPrimary(P resource, Options options) { } /** - * Applies a JSON Merge Patch to the primary resource. - * - *

    Updates the resource and caches the response if needed, thus making sure that next - * reconciliation will see to updated resource - or more recent one if additional update happened - * after this update; In addition to that it filters out the event from this update, so - * reconciliation is not triggered by own update. + * Applies a JSON Merge Patch (RFC 7386) to the primary resource {@code status} subresource, + * caching and filtering the own event through the controller's own event source. Uses the {@link + * UpdateType#JSON_MERGE_PATCH_STATUS} default {@link Matcher}. * - *

    You are free to control the optimistic locking by setting the resource version in resource - * metadata. - * - * @param resource primary resource to patch - * @return updated resource - * @see #jsonMergePatchPrimaryStatus(HasMetadata) + * @param resource the desired primary resource (with the status to merge-patch) + * @return the patched resource as returned by the API server */ public P jsonMergePatchPrimaryStatus(P resource) { return jsonMergePatchPrimaryStatus( resource, Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_MERGE_PATCH_STATUS)); } + /** + * Applies a JSON Merge Patch (RFC 7386) to the primary resource {@code status} subresource, + * caching and filtering the own event through the controller's own event source and using the + * given {@link Options}. + * + * @param resource the desired primary resource (with the status to merge-patch) + * @param options controls caching and own-event filtering; see {@link Options} + * @return the patched resource as returned by the API server + */ public P jsonMergePatchPrimaryStatus(P resource, Options options) { return resourcePatch( resource, @@ -747,6 +853,20 @@ public P jsonMergePatchPrimaryStatus(P resource, Options options) { options); } + /** + * Low-level building block behind all the update/patch operations above: runs the given {@code + * updateOperation} against the API server, then caches the response and (depending on the {@link + * Options}) filters the resulting own event. The target event source is resolved automatically + * from the desired resource's type. Uses {@link Options#filterIfOptimisticLocking()}. + * + * @param resource the desired resource being written + * @param updateOperation the actual write to perform (update, patch, edit, ...) returning the + * server response + * @param the resource type + * @return the resource as returned by {@code updateOperation} + * @throws IllegalStateException if no (or no matching) event source is found for the resource + * type + */ public R resourcePatch(R resource, UnaryOperator updateOperation) { return resourcePatch(resource, updateOperation, Options.filterIfOptimisticLocking()); } @@ -787,6 +907,9 @@ private R resourcePatch( /** * This method is public to ensure backward compatibility, we will make it private in next major * release. + * + * @deprecated use one of the higher-level update/patch methods, or {@link #resourcePatch( + * HasMetadata, HasMetadata, UnaryOperator, ManagedInformerEventSource, Options)} */ @Deprecated(forRemoval = true) public R resourcePatch( @@ -803,6 +926,26 @@ private R resourcePatch( return resourcePatch(desired, null, updateOperation, ies, options); } + /** + * The core update/patch routine used by all operations. Optionally matches the desired against + * the actual (cached) state, performs the write through {@code updateOperation}, and caches the + * response while filtering the own event according to the {@link Options}/{@link Mode}. + * + *

    When a {@link Matcher} is present but {@code actualResource} is {@code null}, the actual + * state is looked up from the given event source's cache. If the states already match, the write + * is skipped and the actual resource is returned unchanged. + * + * @param desiredResource the desired resource; may be {@code null} for JSON Patch operations, + * where {@code actualResource} is used as the base instead + * @param actualResource the actual (current) resource used for matching; may be {@code null} + * @param updateOperation the actual write to perform, returning the server response + * @param ies the event source used for caching and own-event filtering + * @param options controls matching, caching and own-event filtering; see {@link Options} + * @param the resource type + * @return the resource as returned by {@code updateOperation}, or {@code actualResource} when the + * desired state already matches + * @throws IllegalArgumentException if the mode requires a matcher but none is provided + */ // visible for testing R resourcePatch( R desiredResource, @@ -858,6 +1001,7 @@ public P addFinalizer() { * marked for deletion. Note that explicitly adding/removing finalizer is required only if * "Trigger reconciliation on all event" mode is on. * + * @param finalizerName name of the finalizer to add * @return updated resource from the server response */ public P addFinalizer(String finalizerName) { @@ -892,6 +1036,7 @@ public P removeFinalizer() { * explicitly adding/removing finalizer is required only if "Trigger reconciliation on all event" * mode is on. * + * @param finalizerName name of the finalizer to remove * @return updated resource from the server response */ public P removeFinalizer(String finalizerName) { @@ -914,14 +1059,17 @@ public P removeFinalizer(String finalizerName) { } /** - * Patches the resource using JSON Patch. In case the server responds with conflict (HTTP 409) or - * unprocessable content (HTTP 422) it retries the operation up to the maximum number defined in - * {@link ResourceOperations#DEFAULT_MAX_RETRY}. + * Patches the primary resource using JSON Patch, retrying on conflict. The {@code + * resourceChangesOperator} is applied to the current resource before each attempt; if the server + * responds with conflict (HTTP 409) or unprocessable content (HTTP 422) the latest version is + * re-fetched and the operation is retried, up to {@link ResourceOperations#DEFAULT_MAX_RETRY} + * times. * * @param resourceChangesOperator changes to be done on the resource before update * @param preCondition condition to check if the patch operation still needs to be performed or - * not. - * @return updated resource from the server or unchanged if the precondition does not hold. + * not; evaluated against the (possibly re-fetched) resource before each attempt + * @return updated resource from the server or unchanged if the precondition does not hold + * @throws OperatorException if the maximum number of retry attempts is exceeded */ @SuppressWarnings("unchecked") public P conflictRetryingPatchPrimary( @@ -1045,7 +1193,6 @@ public P addFinalizerWithSSA(String finalizerName) { *

  • {@link #cacheOnly()} / {@link #cacheOnly(Matcher)} - only cache the response, no * own-event filtering. *
  • {@link #forceFilterEvents()} - always filter the own event (mostly for internal usage). - * Mostly for internal usage, if we are sure that the resource is matched before. * */ @Experimental(API_MIGHT_CHANGE) @@ -1113,6 +1260,7 @@ public static Options filterIfOptimisticLocking() { * the desired state using the given {@link Matcher}. * * @param matcher the matcher used to decide whether actual already matches desired + * @return options that match using the given matcher and filter the own event */ public static Options matchAndFilter(Matcher matcher) { if (matcher == null) { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonMergePatchMatcher.java similarity index 90% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchMatcher.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonMergePatchMatcher.java index 22a43476d6..6b2ac586df 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchMatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonMergePatchMatcher.java @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.reconciler.matcher; +package io.javaoperatorsdk.operator.processing.matcher; import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher; public class JsonMergePatchMatcher implements Matcher { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchStatusMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonMergePatchStatusMatcher.java similarity index 91% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchStatusMatcher.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonMergePatchStatusMatcher.java index 9840f57077..49be36c80e 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonMergePatchStatusMatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonMergePatchStatusMatcher.java @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.reconciler.matcher; +package io.javaoperatorsdk.operator.processing.matcher; import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher; public class JsonMergePatchStatusMatcher implements Matcher { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchMacher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchMacher.java similarity index 90% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchMacher.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchMacher.java index 7f9f30c442..c4c15d1ae1 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchMacher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchMacher.java @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.reconciler.matcher; +package io.javaoperatorsdk.operator.processing.matcher; import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher; public class JsonPatchMacher implements Matcher { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchStatusMacher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchStatusMacher.java similarity index 90% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchStatusMacher.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchStatusMacher.java index d55d0a6413..dd8e81d5cc 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/JsonPatchStatusMacher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchStatusMacher.java @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.reconciler.matcher; +package io.javaoperatorsdk.operator.processing.matcher; import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher; public class JsonPatchStatusMacher implements Matcher { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/MatcherUtils.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/MatcherUtils.java similarity index 96% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/MatcherUtils.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/MatcherUtils.java index a708dbc826..413a1c4acc 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/MatcherUtils.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/MatcherUtils.java @@ -13,11 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.reconciler.matcher; +package io.javaoperatorsdk.operator.processing.matcher; import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.zjsonpatch.JsonDiff; import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/SSAMatcher.java similarity index 90% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAMatcher.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/SSAMatcher.java index 540861a3b0..c241ccfad5 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAMatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/SSAMatcher.java @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.reconciler.matcher; +package io.javaoperatorsdk.operator.processing.matcher; import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.SSABasedGenericKubernetesResourceMatcher; public class SSAMatcher implements Matcher { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAStatusMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/SSAStatusMatcher.java similarity index 91% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAStatusMatcher.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/SSAStatusMatcher.java index edf4a7d25d..a6a59459f5 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/SSAStatusMatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/SSAStatusMatcher.java @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.reconciler.matcher; +package io.javaoperatorsdk.operator.processing.matcher; import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesResourceMatcher; public class SSAStatusMatcher implements Matcher { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateMatcher.java similarity index 90% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateMatcher.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateMatcher.java index 63f3f7b35e..b47a4b79d5 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateMatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateMatcher.java @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.reconciler.matcher; +package io.javaoperatorsdk.operator.processing.matcher; import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesResourceMatcher; public class UpdateMatcher implements Matcher { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateStatusMatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateStatusMatcher.java similarity index 91% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateStatusMatcher.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateStatusMatcher.java index b2d646367c..cc3b045e9e 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateStatusMatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateStatusMatcher.java @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.reconciler.matcher; +package io.javaoperatorsdk.operator.processing.matcher; import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.GenericKubernetesResourceMatcher; public class UpdateStatusMatcher implements Matcher { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateType.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateType.java similarity index 90% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateType.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateType.java index 05ba0e7ecc..2b09b7a355 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/matcher/UpdateType.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateType.java @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.reconciler.matcher; +package io.javaoperatorsdk.operator.processing.matcher; + +import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher; public enum UpdateType { UPDATE(UpdateMatcher.getInstance()), diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/matcher/PatchMatchersTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/matcher/PatchMatchersTest.java similarity index 99% rename from operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/matcher/PatchMatchersTest.java rename to operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/matcher/PatchMatchersTest.java index 9bf1171100..c283e9741e 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/matcher/PatchMatchersTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/matcher/PatchMatchersTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.reconciler.matcher; +package io.javaoperatorsdk.operator.processing.matcher; import org.junit.jupiter.api.Test; diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/matcher/StatusMatchersTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/matcher/StatusMatchersTest.java similarity index 98% rename from operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/matcher/StatusMatchersTest.java rename to operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/matcher/StatusMatchersTest.java index aa1f8bf63d..5ce3a9fc40 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/matcher/StatusMatchersTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/matcher/StatusMatchersTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.api.reconciler.matcher; +package io.javaoperatorsdk.operator.processing.matcher; import org.junit.jupiter.api.Test; From 0630f4f76ebbbac0babea5f5855f057d9752aa46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 14 Jul 2026 15:11:46 +0200 Subject: [PATCH 26/35] test fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- docs/content/en/blog/releases/v5-5-release.md | 86 ++++++++++++++++++- .../ResourceOperationsReconciler.java | 14 ++- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/docs/content/en/blog/releases/v5-5-release.md b/docs/content/en/blog/releases/v5-5-release.md index 1c7da20ecc..17126317a1 100644 --- a/docs/content/en/blog/releases/v5-5-release.md +++ b/docs/content/en/blog/releases/v5-5-release.md @@ -5,14 +5,98 @@ author: >- [Attila Mészáros](https://github.com/csviri) --- -We're pleased to announce the release of Java Operator SDK v5.5.0! +We're pleased to announce the release of Java Operator SDK v5.5.0! This minor version reworks how +the framework filters the events caused by a controller's own writes, making it more correct and +more efficient, and exposes a richer, matcher-aware `ResourceOperations` API. There are **no +breaking API changes**, but there is one behavioral change around `UpdateControl` — see the +migration notes. ## Key Features +### Matcher-based updates in `ResourceOperations` + +`ResourceOperations` (available from the reconciliation `Context` via `context.resourceOperations()`) +now offers a complete, consistent family of update/patch/create methods for every write strategy — +**server-side apply, update (PUT), JSON Patch (RFC 6902) and JSON Merge Patch (RFC 7386)** — each +available for the whole resource and the `status` subresource, and with dedicated `primary` +variants. + +Every method comes in two flavors: a default one, and one taking an `Options` argument that controls +how the resulting own event is handled: + +```java +public UpdateControl reconcile(WebPage webPage, Context context) { + makeStatusChanges(webPage); + // updates the status, filters the own event, and skips the write entirely if nothing changed + context.resourceOperations().serverSideApplyPrimaryStatus(webPage); + return UpdateControl.noUpdate(); +} +``` + +By default these operations **match the desired state against the actual (cached) state before +writing**: if they already match, the write is skipped; otherwise the write is performed and its own +event is filtered. This is the most efficient option — it covers full event filtering *and* avoids a +request to the Kubernetes API server when nothing changed. Default matchers are provided for every +operation type; when one does not fit, supply your own via `Options.matchAndFilter(matcher)`. + +`Options` exposes the available strategies: + +- `matchAndFilter(...)` / `matchAndFilterWithDefaultMatcher(...)` — match, then write-and-filter only + if needed (the default). +- `filterIfOptimisticLocking()` — filter the own event only when the write uses optimistic locking, + otherwise just cache the response. +- `cacheOnly()` — only cache the response (read-cache-after-write consistency), no filtering. +- `forceFilterEvents()` — always filter (mostly internal usage). + +> **Correctness note**: filtering an own event is only safe if the framework can tell an own write +> apart from a concurrent third-party write. This requires **either** a matcher **or** optimistic +> locking; otherwise a concurrent external update inside the filter window may be missed until the +> next resync. + +### More correct own-event filtering (no-op update edge case) + +The read-cache-after-write own-event filter has been reworked to fix an edge case where a legitimate +external change could be filtered out together with a controller's own **no-op** update — for +example, when the spec was changed externally while the controller patched its status and that status +patch turned out to be a no-op. The new matcher-based operations avoid issuing such no-op writes in +the first place, and the filtering itself is now correct in these concurrent scenarios. + ## Additional Improvements +- **`GenericKubernetesResourceMatcher.matchStatus(...)`**: a status-only counterpart to `match(...)` + that compares just the `/status` subtree. +- **New `Matcher` SPI**: `io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher` lets you plug a + custom matching strategy into `Options.matchAndFilter(...)`. +- Extensive integration tests covering every `ResourceOperations` update/patch operation (primary, + status, and secondary resources) against a real cluster. + ## Migration Notes +There are **no breaking API changes**; existing code compiles and runs unchanged. However: + +### `UpdateControl` no longer filters own events by default + +Returning an `UpdateControl` (or `ErrorStatusUpdateControl`) still updates the resource and keeps the +cache read-after-write consistent, but it **no longer filters the resulting own event by default** — +so the write may cause an additional (idempotent) reconciliation. This change fixes correctness edge +cases where an event that should have propagated was previously swallowed. + +If you relied on the previous filtering, perform the update through `ResourceOperations` and return +`UpdateControl.noUpdate()`: + +```java +// before (v5.4) +resource.setStatus(new MyStatus().setReady(true)); +return UpdateControl.patchStatus(resource); + +// after (v5.5) — filter the own event explicitly +resource.setStatus(new MyStatus().setReady(true)); +context.resourceOperations().serverSideApplyPrimaryStatus(resource); +return UpdateControl.noUpdate(); +``` + +See the [migration guide](/docs/migration/v5-5-migration) for details. + ## Getting Started ```xml diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsReconciler.java index d747b515a9..135f6f8bc6 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsReconciler.java @@ -17,6 +17,7 @@ import java.util.concurrent.atomic.AtomicInteger; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; @@ -65,13 +66,20 @@ public UpdateControl reconcile( case SSA -> { markResource(resource); // SSA does not use optimistic locking + resource.getMetadata().setManagedFields(null); resource.getMetadata().setResourceVersion(null); ops.serverSideApplyPrimary(resource); } case SSA_STATUS -> { - resource.setStatus(new ResourceOperationsStatus().setValue(STATUS_VALUE)); - resource.getMetadata().setResourceVersion(null); - ops.serverSideApplyPrimaryStatus(resource); + ResourceOperationsCustomResource fresh = new ResourceOperationsCustomResource(); + fresh.setMetadata(new ObjectMetaBuilder() + .withName(resource.getMetadata().getName()) + .withNamespace(resource.getMetadata().getNamespace()) + .build()); + + fresh.setStatus(new ResourceOperationsStatus().setValue(STATUS_VALUE)); + fresh.getMetadata().setResourceVersion(null); + ops.serverSideApplyPrimaryStatus(fresh); } case UPDATE -> { markResource(resource); From 23a88e15ed2fc51069557fc4f1ad6a84e7e4901c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 14 Jul 2026 15:18:05 +0200 Subject: [PATCH 27/35] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../resourceoperations/ResourceOperationsReconciler.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsReconciler.java index 135f6f8bc6..bbfb7cd098 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/resourceoperations/ResourceOperationsReconciler.java @@ -72,9 +72,10 @@ public UpdateControl reconcile( } case SSA_STATUS -> { ResourceOperationsCustomResource fresh = new ResourceOperationsCustomResource(); - fresh.setMetadata(new ObjectMetaBuilder() - .withName(resource.getMetadata().getName()) - .withNamespace(resource.getMetadata().getNamespace()) + fresh.setMetadata( + new ObjectMetaBuilder() + .withName(resource.getMetadata().getName()) + .withNamespace(resource.getMetadata().getNamespace()) .build()); fresh.setStatus(new ResourceOperationsStatus().setValue(STATUS_VALUE)); From 82d9d97f3ce931a322cc9abf933875eb6e89c7bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 14 Jul 2026 15:25:51 +0200 Subject: [PATCH 28/35] typo fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../{JsonPatchMacher.java => JsonPatchMatcher.java} | 8 ++++---- ...PatchStatusMacher.java => JsonPatchStatusMatcher.java} | 8 ++++---- .../operator/processing/matcher/UpdateType.java | 4 ++-- .../operator/processing/matcher/PatchMatchersTest.java | 5 +++-- 4 files changed, 13 insertions(+), 12 deletions(-) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/{JsonPatchMacher.java => JsonPatchMatcher.java} (84%) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/{JsonPatchStatusMacher.java => JsonPatchStatusMatcher.java} (82%) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchMacher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchMatcher.java similarity index 84% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchMacher.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchMatcher.java index c4c15d1ae1..7ac65be9a6 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchMacher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchMatcher.java @@ -19,15 +19,15 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher; -public class JsonPatchMacher implements Matcher { +public class JsonPatchMatcher implements Matcher { - private static final JsonPatchMacher INSTANCE = new JsonPatchMacher(); + private static final JsonPatchMatcher INSTANCE = new JsonPatchMatcher(); - public static JsonPatchMacher getInstance() { + public static JsonPatchMatcher getInstance() { return INSTANCE; } - private JsonPatchMacher() {} + private JsonPatchMatcher() {} @Override public boolean matches(HasMetadata desired, HasMetadata actual, Context context) { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchStatusMacher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchStatusMatcher.java similarity index 82% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchStatusMacher.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchStatusMatcher.java index dd8e81d5cc..2fcc1aebe4 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchStatusMacher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/JsonPatchStatusMatcher.java @@ -19,15 +19,15 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.matcher.Matcher; -public class JsonPatchStatusMacher implements Matcher { +public class JsonPatchStatusMatcher implements Matcher { - private static final JsonPatchStatusMacher INSTANCE = new JsonPatchStatusMacher(); + private static final JsonPatchStatusMatcher INSTANCE = new JsonPatchStatusMatcher(); - public static JsonPatchStatusMacher getInstance() { + public static JsonPatchStatusMatcher getInstance() { return INSTANCE; } - private JsonPatchStatusMacher() {} + private JsonPatchStatusMatcher() {} @Override public boolean matches(HasMetadata desired, HasMetadata actual, Context context) { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateType.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateType.java index 2b09b7a355..327a9144df 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateType.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/matcher/UpdateType.java @@ -22,8 +22,8 @@ public enum UpdateType { UPDATE_STATUS(UpdateStatusMatcher.getInstance()), SSA(SSAMatcher.getInstance()), SSA_STATUS(SSAStatusMatcher.getInstance()), - JSON_PATCH(JsonPatchMacher.getInstance()), - JSON_PATCH_STATUS(JsonPatchStatusMacher.getInstance()), + JSON_PATCH(JsonPatchMatcher.getInstance()), + JSON_PATCH_STATUS(JsonPatchStatusMatcher.getInstance()), JSON_MERGE_PATCH(JsonMergePatchMatcher.getInstance()), JSON_MERGE_PATCH_STATUS(JsonMergePatchStatusMatcher.getInstance()); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/matcher/PatchMatchersTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/matcher/PatchMatchersTest.java index c283e9741e..b52d0fc250 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/matcher/PatchMatchersTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/matcher/PatchMatchersTest.java @@ -32,9 +32,10 @@ class PatchMatchersTest { private static final Context context = new TestContext(); - private final JsonPatchMacher jsonPatchMatcher = JsonPatchMacher.getInstance(); + private final JsonPatchMatcher jsonPatchMatcher = JsonPatchMatcher.getInstance(); private final JsonMergePatchMatcher mergePatchMatcher = JsonMergePatchMatcher.getInstance(); - private final JsonPatchStatusMacher jsonPatchStatusMatcher = JsonPatchStatusMacher.getInstance(); + private final JsonPatchStatusMatcher jsonPatchStatusMatcher = + JsonPatchStatusMatcher.getInstance(); private final JsonMergePatchStatusMatcher mergePatchStatusMatcher = JsonMergePatchStatusMatcher.getInstance(); From f245dd160ff7ba5fe9d70e15c37f7b15d0fa0ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 14 Jul 2026 15:32:43 +0200 Subject: [PATCH 29/35] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/api/reconciler/ResourceOperations.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 43f7a8f209..927ed72070 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -539,7 +539,9 @@ public R jsonPatch(R actualResource, UnaryOperator un */ public R jsonPatch( R actualResource, UnaryOperator unaryOperator, Options options) { + R desired = desiredForJsonPatch(actualResource, unaryOperator, options); return resourcePatch( + desired, actualResource, r -> context.getClient().resource(actualResource).edit(unaryOperator), options); @@ -561,8 +563,9 @@ public R jsonPatch( UnaryOperator unaryOperator, InformerEventSource informerEventSource, Options options) { - + R desired = desiredForJsonPatch(actualResource, unaryOperator, options); return resourcePatch( + desired, actualResource, r -> context.getClient().resource(actualResource).edit(unaryOperator), informerEventSource, From c5c9e8fe48bb4fd4da15274d46b757012c4bf82f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 14 Jul 2026 16:22:21 +0200 Subject: [PATCH 30/35] IT fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/reconciler/ResourceOperations.java | 65 +++++++++++++++++-- .../event/ReconciliationDispatcher.java | 2 +- .../event/ReconciliationDispatcherTest.java | 2 +- .../FilterPatchEventTestReconciler.java | 7 +- .../baseapi/simple/ReconcilerExecutorIT.java | 2 +- 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 927ed72070..743f52b8db 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -995,7 +995,33 @@ R resourcePatch( * @see #addFinalizer(String) */ public P addFinalizer() { - return addFinalizer(context.getControllerConfiguration().getFinalizerName()); + return addFinalizer(context.getControllerConfiguration().getFinalizerName(), false); + } + + /** + * Adds the default finalizer (from controller configuration) to the primary resource. This is a + * convenience method that calls {@link #addFinalizer(String, boolean)} with the configured + * finalizer name. + * + * @param cacheOnly if {@code true} the finalizer patch does not filter its own event (see {@link + * #addFinalizer(String, boolean)}) + * @return updated resource from the server response + * @see #addFinalizer(String, boolean) + */ + public P addFinalizer(boolean cacheOnly) { + return addFinalizer(context.getControllerConfiguration().getFinalizerName(), cacheOnly); + } + + /** + * Adds the given finalizer to the primary resource, filtering the own event (equivalent to {@link + * #addFinalizer(String, boolean)} with {@code cacheOnly = false}). + * + * @param finalizerName name of the finalizer to add + * @return updated resource from the server response + * @see #addFinalizer(String, boolean) + */ + public P addFinalizer(String finalizerName) { + return addFinalizer(finalizerName, false); } /** @@ -1005,9 +1031,13 @@ public P addFinalizer() { * "Trigger reconciliation on all event" mode is on. * * @param finalizerName name of the finalizer to add + * @param cacheOnly if {@code true} the finalizer patch only caches the response and does + * not filter the resulting own event, so a subsequent reconciliation is triggered by + * the finalizer addition; if {@code false} the default JSON Patch matcher is used and the own + * event is filtered * @return updated resource from the server response */ - public P addFinalizer(String finalizerName) { + public P addFinalizer(String finalizerName, boolean cacheOnly) { var resource = context.getPrimaryResource(); if (resource.isMarkedForDeletion() || resource.hasFinalizer(finalizerName)) { return resource; @@ -1017,7 +1047,8 @@ public P addFinalizer(String finalizerName) { r.addFinalizer(finalizerName); return r; }, - r -> !r.hasFinalizer(finalizerName)); + r -> !r.hasFinalizer(finalizerName), + cacheOnly); } /** @@ -1061,6 +1092,22 @@ public P removeFinalizer(String finalizerName) { }); } + /** + * Patches the primary resource using JSON Patch, retrying on conflict, filtering the own event + * via the default JSON Patch matcher (equivalent to {@link + * #conflictRetryingPatchPrimary(UnaryOperator, Predicate, boolean)} with {@code cacheOnly = + * false}). + * + * @param resourceChangesOperator changes to be done on the resource before update + * @param preCondition condition to check if the patch operation still needs to be performed or + * not + * @return updated resource from the server or unchanged if the precondition does not hold + */ + public P conflictRetryingPatchPrimary( + UnaryOperator

    resourceChangesOperator, Predicate

    preCondition) { + return conflictRetryingPatchPrimary(resourceChangesOperator, preCondition, false); + } + /** * Patches the primary resource using JSON Patch, retrying on conflict. The {@code * resourceChangesOperator} is applied to the current resource before each attempt; if the server @@ -1071,12 +1118,15 @@ public P removeFinalizer(String finalizerName) { * @param resourceChangesOperator changes to be done on the resource before update * @param preCondition condition to check if the patch operation still needs to be performed or * not; evaluated against the (possibly re-fetched) resource before each attempt + * @param cacheOnly if {@code true} the patch only caches the response and does not + * filter the resulting own event; if {@code false} the default JSON Patch matcher is used and + * the own event is filtered * @return updated resource from the server or unchanged if the precondition does not hold * @throws OperatorException if the maximum number of retry attempts is exceeded */ @SuppressWarnings("unchecked") public P conflictRetryingPatchPrimary( - UnaryOperator

    resourceChangesOperator, Predicate

    preCondition) { + UnaryOperator

    resourceChangesOperator, Predicate

    preCondition, boolean cacheOnly) { var resource = context.getPrimaryResource(); var client = context.getClient(); if (log.isDebugEnabled()) { @@ -1088,7 +1138,12 @@ public P conflictRetryingPatchPrimary( if (!preCondition.test(resource)) { return resource; } - return jsonPatchPrimary(resource, resourceChangesOperator); + return jsonPatchPrimary( + resource, + resourceChangesOperator, + cacheOnly + ? Options.cacheOnly() + : Options.matchAndFilterWithDefaultMatcher(UpdateType.JSON_PATCH)); } catch (KubernetesClientException e) { log.trace("Exception during patch for resource: {}", resource); retryIndex++; diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java index f826ad06d0..dfdf37d3e1 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java @@ -137,7 +137,7 @@ private PostExecutionControl

    handleReconcile( if (useSSA) { updatedResource = context.resourceOperations().addFinalizerWithSSA(); } else { - updatedResource = context.resourceOperations().addFinalizer(); + updatedResource = context.resourceOperations().addFinalizer(true); } return PostExecutionControl.onlyFinalizerAdded(updatedResource); } else { diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcherTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcherTest.java index c7d9458695..e874731657 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcherTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcherTest.java @@ -169,7 +169,7 @@ void addFinalizerOnNewResourceWithoutSSA() throws Exception { dispatcher.handleDispatch(executionScopeWithCREvent(testCustomResource), createTestContext()); verify(reconciler, never()).reconcile(ArgumentMatchers.eq(testCustomResource), any()); - verify(mockResourceOperations, times(1)).addFinalizer(); + verify(mockResourceOperations, times(1)).addFinalizer(true); } @Test diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/filterpatchevent/FilterPatchEventTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/filterpatchevent/FilterPatchEventTestReconciler.java index 1f19015dcd..59bf33cafc 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/filterpatchevent/FilterPatchEventTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/filterpatchevent/FilterPatchEventTestReconciler.java @@ -42,11 +42,12 @@ public UpdateControl reconcile( resource.setStatus(new FilterPatchEventTestCustomResourceStatus()); resource.getStatus().setValue(UPDATED); - var uc = UpdateControl.patchStatus(resource); + context.resourceOperations().jsonMergePatchPrimaryStatus(resource); if (!filterPatchEvent.get()) { - uc = uc.reschedule(); + return UpdateControl.noUpdate().reschedule(); + } else { + return UpdateControl.noUpdate(); } - return uc; } public int getNumberOfExecutions() { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/ReconcilerExecutorIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/ReconcilerExecutorIT.java index 54d639c05a..b6790e4085 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/ReconcilerExecutorIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/simple/ReconcilerExecutorIT.java @@ -52,7 +52,7 @@ void configMapGetsCreatedForTestCustomResource() { awaitResourcesCreatedOrUpdated(); awaitStatusUpdated(); - assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(1); + assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(2); } @Test From c83ab8b5faba6463bf9f448ffbad387e339230c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 14 Jul 2026 17:56:34 +0200 Subject: [PATCH 31/35] test: fix flaky ManualObservedGenerationIT by guarding null status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status assertions could throw NPE on the first Awaitility poll before the reconciler patched the status. untilAsserted retries AssertionErrors but rethrows other exceptions immediately, so the NPE aborted the test instead of retrying. Guard with assertThat(status).isNotNull() to keep polling. Signed-off-by: Attila Mészáros --- .../manualobservedgeneration/ManualObservedGenerationIT.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/manualobservedgeneration/ManualObservedGenerationIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/manualobservedgeneration/ManualObservedGenerationIT.java index 9de22c4a2a..40331bb3b3 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/manualobservedgeneration/ManualObservedGenerationIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/manualobservedgeneration/ManualObservedGenerationIT.java @@ -52,6 +52,7 @@ void observedGenerationUpdated() { () -> { var r = extension.get(ManualObservedGenerationCustomResource.class, RESOURCE_NAME); assertThat(r).isNotNull(); + assertThat(r.getStatus()).isNotNull(); assertThat(r.getStatus().getObservedGeneration()).isEqualTo(1); assertThat(r.getStatus().getObservedGeneration()) .isEqualTo(r.getMetadata().getGeneration()); @@ -65,6 +66,8 @@ void observedGenerationUpdated() { .untilAsserted( () -> { var r = extension.get(ManualObservedGenerationCustomResource.class, RESOURCE_NAME); + assertThat(r).isNotNull(); + assertThat(r.getStatus()).isNotNull(); assertThat(r.getStatus().getObservedGeneration()).isEqualTo(2); assertThat(r.getStatus().getObservedGeneration()) .isEqualTo(r.getMetadata().getGeneration()); From 32b16f5fb560930f5c381673b538f595340c8eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 14 Jul 2026 18:16:01 +0200 Subject: [PATCH 32/35] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/api/reconciler/ResourceOperations.java | 12 ++++++++++-- .../source/informer/ManagedInformerEventSource.java | 6 ++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 743f52b8db..5a610f4e70 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -970,11 +970,19 @@ R resourcePatch( if (options.requiresMatcher() && matcher == null) { throw new IllegalArgumentException("Mode : " + options.mode + " requires matcher"); } + // this is to cover special case for jsonPatch were we should use actual resource as base + var targetBaseResource = desiredResource != null ? desiredResource : actualResource; if (matches) { + if (log.isDebugEnabled()) { + log.debug( + "Resource match resource id: {}, type: {}, version: {}", + ResourceID.fromResource(targetBaseResource), + targetBaseResource.getClass().getSimpleName(), + targetBaseResource.getMetadata().getResourceVersion()); + } return actualResource; } - // this is to cover special case for jsonPatch were we should use actual resource as base - var targetBaseResource = desiredResource != null ? desiredResource : actualResource; + boolean optimisticLocking = targetBaseResource.getMetadata().getResourceVersion() != null; if (options.getMode() == Mode.CACHE_ONLY diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java index 4a29a42144..78f6da6f78 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java @@ -141,6 +141,12 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator< @Experimental(API_MIGHT_CHANGE) public R updateAndCacheResource(R resourceToUpdate, UnaryOperator updateOperation) { + if (log.isDebugEnabled()) { + log.debug( + "Updating and caching resource without filtering. id: {} type: {}", + ResourceID.fromResource(resourceToUpdate), + resourceType()); + } var result = updateOperation.apply(resourceToUpdate); handleRecentResourceUpdate(ResourceID.fromResource(resourceToUpdate), result, resourceToUpdate); return result; From 2d7c490545510dc0e28aadd0d8bd06484f883e5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 14 Jul 2026 18:17:48 +0200 Subject: [PATCH 33/35] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/api/reconciler/ResourceOperations.java | 2 +- .../event/source/informer/ManagedInformerEventSource.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 5a610f4e70..18202946c1 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -975,7 +975,7 @@ R resourcePatch( if (matches) { if (log.isDebugEnabled()) { log.debug( - "Resource match resource id: {}, type: {}, version: {}", + "Resource match resource id: {}, type: {}, version: {}", ResourceID.fromResource(targetBaseResource), targetBaseResource.getClass().getSimpleName(), targetBaseResource.getMetadata().getResourceVersion()); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java index 78f6da6f78..8352bef665 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java @@ -101,8 +101,8 @@ public void changeNamespaces(Set namespaces) { public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator updateMethod) { ResourceID id = ResourceID.fromResource(resourceToUpdate); - log.debug("Starting event filtering and caching update for id={}", id); - R updatedResource = null; + log.debug("Starting event filtering and caching update for id: {}", id); + R updatedResource; Set relatedPrimaryIds = null; try { temporaryResourceCache.startEventFilteringModify(id); From 70852d4ef5385e5f1d269999caddf9a2cb587218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 14 Jul 2026 21:34:04 +0200 Subject: [PATCH 34/35] test fixes and improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/reconciler/ResourceOperations.java | 38 +++++++++---------- .../SpecChangeDuringStatusPatchIT.java | 2 +- .../SubResourceTestCustomReconciler.java | 12 +++++- .../WorkflowMultipleActivationIT.java | 27 +++++++++++-- 4 files changed, 51 insertions(+), 28 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 18202946c1..c84e0a55b9 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -543,7 +543,7 @@ public R jsonPatch( return resourcePatch( desired, actualResource, - r -> context.getClient().resource(actualResource).edit(unaryOperator), + r -> context.getClient().resource(actualResource).edit(rr -> desired), options); } @@ -607,7 +607,7 @@ public R jsonPatchStatus( return resourcePatch( desired, actualResource, - r -> context.getClient().resource(r).editStatus(unaryOperator), + r -> context.getClient().resource(actualResource).editStatus(rr -> desired), options); } @@ -632,7 +632,7 @@ public R jsonPatchStatus( return resourcePatch( desired, actualResource, - r -> context.getClient().resource(r).editStatus(unaryOperator), + r -> context.getClient().resource(actualResource).editStatus(rr -> desired), informerEventSource, options); } @@ -667,7 +667,7 @@ public P jsonPatchPrimary(P actualResource, UnaryOperator

    unaryOperator, Opti return resourcePatch( desired, actualResource, - r -> context.getClient().resource(actualResource).edit(unaryOperator), + r -> context.getClient().resource(actualResource).edit(rr -> desired), context.eventSourceRetriever().getControllerEventSource(), options); } @@ -971,25 +971,24 @@ R resourcePatch( throw new IllegalArgumentException("Mode : " + options.mode + " requires matcher"); } // this is to cover special case for jsonPatch were we should use actual resource as base - var targetBaseResource = desiredResource != null ? desiredResource : actualResource; if (matches) { if (log.isDebugEnabled()) { log.debug( "Resource match resource id: {}, type: {}, version: {}", - ResourceID.fromResource(targetBaseResource), - targetBaseResource.getClass().getSimpleName(), - targetBaseResource.getMetadata().getResourceVersion()); + ResourceID.fromResource(desiredResource), + desiredResource.getClass().getSimpleName(), + desiredResource.getMetadata().getResourceVersion()); } return actualResource; } - boolean optimisticLocking = targetBaseResource.getMetadata().getResourceVersion() != null; + boolean optimisticLocking = desiredResource.getMetadata().getResourceVersion() != null; if (options.getMode() == Mode.CACHE_ONLY || (options.getMode() == Mode.FILTER_IF_OPTIMISTIC_LOCKING && !optimisticLocking)) { - return ies.updateAndCacheResource(targetBaseResource, updateOperation); + return ies.updateAndCacheResource(desiredResource, updateOperation); } else { - return ies.eventFilteringUpdateAndCacheResource(targetBaseResource, updateOperation); + return ies.eventFilteringUpdateAndCacheResource(desiredResource, updateOperation); } } @@ -1381,15 +1380,12 @@ enum Mode { private T desiredForJsonPatch( T actualResource, UnaryOperator unaryOperator, Options options) { - if (options.getMatcher().isPresent()) { - var cloned = - context - .getControllerConfiguration() - .getConfigurationService() - .getResourceCloner() - .clone(actualResource); - return unaryOperator.apply(cloned); - } - return null; + var cloned = + context + .getControllerConfiguration() + .getConfigurationService() + .getResourceCloner() + .clone(actualResource); + return unaryOperator.apply(cloned); } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchIT.java index 93caa2fad1..30df830e58 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchIT.java @@ -49,7 +49,7 @@ class SpecChangeDuringStatusPatchIT { LocallyRunOperatorExtension extension = LocallyRunOperatorExtension.builder().withReconciler(reconciler).build(); - @RepeatedTest(10) + @RepeatedTest(3) void specChangeDuringStatusPatchIsReconciled() throws InterruptedException { var res = extension.create(testResource()); var statusRes = testResource(); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomReconciler.java index 9c270d6164..a4a2f6398a 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomReconciler.java @@ -44,9 +44,17 @@ public UpdateControl reconcile( log.info("Value: " + resource.getSpec().getValue()); ensureStatusExists(resource); - resource.getStatus().setState(SubResourceTestCustomResourceStatus.State.SUCCESS); + waitXms(RECONCILER_MIN_EXEC_TIME); - return UpdateControl.patchStatus(resource); + context + .resourceOperations() + .jsonPatchPrimaryStatus( + resource, + r -> { + r.getStatus().setState(SubResourceTestCustomResourceStatus.State.SUCCESS); + return r; + }); + return UpdateControl.noUpdate(); } private void ensureStatusExists(SubResourceTestCustomResource resource) { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/WorkflowMultipleActivationIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/WorkflowMultipleActivationIT.java index ce80198c62..93cd2c5e06 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/WorkflowMultipleActivationIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/workflow/workflowmultipleactivation/WorkflowMultipleActivationIT.java @@ -16,6 +16,7 @@ package io.javaoperatorsdk.operator.workflow.workflowmultipleactivation; import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -115,10 +116,7 @@ void deactivatingAndReactivatingDependent() { assertThat(cm.getData()).containsEntry(DATA_KEY, CHANGED_VALUE); }); - var numOfReconciliation = - extension - .getReconcilerOfType(WorkflowMultipleActivationReconciler.class) - .getNumberOfReconciliationExecution(); + var numOfReconciliation = awaitStableReconciliationCount(); var actualCM = extension.get(ConfigMap.class, TEST_RESOURCE1); actualCM.getData().put("data2", "additionaldata"); extension.replace(actualCM); @@ -146,6 +144,27 @@ void deactivatingAndReactivatingDependent() { }); } + /** + * Snapshots the reconciliation count only once it has stopped changing. The framework adds the + * finalizer with a cache-only (non event-filtered) write, so each (re)creation of the resource + * emits a finalizer-add event that drives a follow-up reconciliation. A few of these may still be + * trailing from the preceding create/delete/recreate and spec changes; capturing the count before + * they settle would race with them and inflate the later assertion. + */ + private int awaitStableReconciliationCount() { + var reconciler = extension.getReconcilerOfType(WorkflowMultipleActivationReconciler.class); + var lastSeen = new AtomicInteger(-1); + await() + .pollInterval(Duration.ofMillis(POLL_DELAY)) + .atMost(Duration.ofSeconds(10)) + .until( + () -> { + int current = reconciler.getNumberOfReconciliationExecution(); + return current == lastSeen.getAndSet(current); + }); + return lastSeen.get(); + } + WorkflowMultipleActivationCustomResource testResource(String name) { var res = new WorkflowMultipleActivationCustomResource(); res.setMetadata(new ObjectMetaBuilder().withName(name).build()); From 7e89ea1136478d4d7201c2c11f923fda55911cb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 14 Jul 2026 22:32:16 +0200 Subject: [PATCH 35/35] tests and test fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/reconciler/ResourceOperations.java | 4 +- .../reconciler/ResourceOperationsTest.java | 233 ++++++++++++++++++ .../SubResourceTestCustomReconciler.java | 3 +- .../subresource/SubResourceUpdateIT.java | 6 +- 4 files changed, 239 insertions(+), 7 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index c84e0a55b9..be71bd779a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -567,7 +567,7 @@ public R jsonPatch( return resourcePatch( desired, actualResource, - r -> context.getClient().resource(actualResource).edit(unaryOperator), + r -> context.getClient().resource(actualResource).edit(rr -> desired), informerEventSource, options); } @@ -704,7 +704,7 @@ public P jsonPatchPrimaryStatus( return resourcePatch( desired, actualResource, - r -> context.getClient().resource(actualResource).status().edit(unaryOperator), + r -> context.getClient().resource(actualResource).status().edit(rr -> desired), context.eventSourceRetriever().getControllerEventSource(), options); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java index 7cb26ce187..3dd9626f18 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java @@ -17,16 +17,20 @@ import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.function.UnaryOperator; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.stubbing.Answer; import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.KubernetesClientException; import io.fabric8.kubernetes.client.dsl.MixedOperation; +import io.fabric8.kubernetes.client.dsl.NamespaceableResource; import io.fabric8.kubernetes.client.dsl.Resource; +import io.fabric8.kubernetes.client.dsl.base.PatchContext; import io.fabric8.kubernetes.client.utils.KubernetesSerialization; import io.javaoperatorsdk.operator.TestUtils; import io.javaoperatorsdk.operator.api.config.Cloner; @@ -56,6 +60,12 @@ class ResourceOperationsTest { private ControllerEventSource controllerEventSource; private ResourceOperations resourceOperations; + @SuppressWarnings("rawtypes") + private ManagedInformerEventSource verbEventSource; + + @SuppressWarnings("rawtypes") + private NamespaceableResource verbClientResource; + @BeforeEach void setupMocks() { context = mock(Context.class); @@ -448,4 +458,227 @@ void createRejectsMatcher() { assertThat(exception.getMessage()).contains("does not support matcher"); } + + // --------------------------------------------------------------------------- + // High-level update / create / patch verbs with different Options variations. + // These go through the full public API (resolving the event source from the + // retriever and invoking the real client verb), verifying both the routing + // (filter vs cache) and which underlying Kubernetes operation is used. + // --------------------------------------------------------------------------- + + /** + * Wires a {@link ManagedInformerEventSource} into the retriever and a client {@link Resource} so + * that both cache paths actually run the update operation, letting us assert which client verb is + * invoked. Returns the resource the client verbs are stubbed to return. + */ + @SuppressWarnings("rawtypes") + private TestCustomResource wireVerbMocks() { + verbEventSource = mock(ManagedInformerEventSource.class); + when(context.eventSourceRetriever().getEventSourcesFor(TestCustomResource.class)) + .thenReturn(List.of(verbEventSource)); + + verbClientResource = mock(NamespaceableResource.class); + when(context.getClient().resource(any(HasMetadata.class))).thenReturn(verbClientResource); + + var updated = TestUtils.testCustomResource1(); + updated.getMetadata().setResourceVersion("999"); + when(verbClientResource.update()).thenReturn(updated); + when(verbClientResource.create()).thenReturn(updated); + when(verbClientResource.updateStatus()).thenReturn(updated); + when(verbClientResource.patch()).thenReturn(updated); + when(verbClientResource.patch(any(PatchContext.class))).thenReturn(updated); + + // both cache paths execute the update operation so the underlying client verb runs + Answer runOperation = + invocation -> ((UnaryOperator) invocation.getArgument(1)).apply(invocation.getArgument(0)); + when(verbEventSource.eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class))) + .thenAnswer(runOperation); + when(verbEventSource.updateAndCacheResource(any(), any(UnaryOperator.class))) + .thenAnswer(runOperation); + return updated; + } + + // ---- update ------------------------------------------------------------- + + @Test + void updateCacheOnlyCachesAndCallsClientUpdate() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + var updated = wireVerbMocks(); + + var result = resourceOperations.update(resource, ResourceOperations.Options.cacheOnly()); + + assertThat(result).isSameAs(updated); + verify(verbEventSource, times(1)) + .updateAndCacheResource(eq(resource), any(UnaryOperator.class)); + verify(verbEventSource, never()) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + verify(verbClientResource, times(1)).update(); + } + + @Test + void updateForceFilterFiltersAndCallsClientUpdate() { + var resource = TestUtils.testCustomResource1(); + var updated = wireVerbMocks(); + + var result = + resourceOperations.update(resource, ResourceOperations.Options.forceFilterEvents()); + + assertThat(result).isSameAs(updated); + verify(verbEventSource, times(1)) + .eventFilteringUpdateAndCacheResource(eq(resource), any(UnaryOperator.class)); + verify(verbEventSource, never()).updateAndCacheResource(any(), any(UnaryOperator.class)); + verify(verbClientResource, times(1)).update(); + } + + @Test + void updateFilterIfOptimisticLockingFiltersWhenResourceVersionPresent() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion("1"); + wireVerbMocks(); + + resourceOperations.update(resource, ResourceOperations.Options.filterIfOptimisticLocking()); + + verify(verbEventSource, times(1)) + .eventFilteringUpdateAndCacheResource(eq(resource), any(UnaryOperator.class)); + verify(verbEventSource, never()).updateAndCacheResource(any(), any(UnaryOperator.class)); + } + + @Test + void updateFilterIfOptimisticLockingCachesWhenNoResourceVersion() { + var resource = TestUtils.testCustomResource1(); + resource.getMetadata().setResourceVersion(null); + wireVerbMocks(); + + resourceOperations.update(resource, ResourceOperations.Options.filterIfOptimisticLocking()); + + verify(verbEventSource, times(1)) + .updateAndCacheResource(eq(resource), any(UnaryOperator.class)); + verify(verbEventSource, never()) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + } + + @Test + void updateWithMatcherMatchingSkipsWrite() { + var desired = TestUtils.testCustomResource1(); + var actual = TestUtils.testCustomResource1(); + wireVerbMocks(); + when(verbEventSource.get(any())).thenReturn(Optional.of(actual)); + var matcher = mock(Matcher.class); + when(matcher.matches(any(), any(), any())).thenReturn(true); + + var result = + resourceOperations.update(desired, ResourceOperations.Options.matchAndFilter(matcher)); + + assertThat(result).isSameAs(actual); + verify(verbEventSource, never()) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + verify(verbEventSource, never()).updateAndCacheResource(any(), any(UnaryOperator.class)); + verify(verbClientResource, never()).update(); + } + + @Test + void updateWithMatcherNotMatchingFiltersAndWrites() { + var desired = TestUtils.testCustomResource1(); + var actual = TestUtils.testCustomResource1(); + var updated = wireVerbMocks(); + when(verbEventSource.get(any())).thenReturn(Optional.of(actual)); + var matcher = mock(Matcher.class); + when(matcher.matches(any(), any(), any())).thenReturn(false); + + var result = + resourceOperations.update(desired, ResourceOperations.Options.matchAndFilter(matcher)); + + assertThat(result).isSameAs(updated); + verify(verbEventSource, times(1)) + .eventFilteringUpdateAndCacheResource(eq(desired), any(UnaryOperator.class)); + verify(verbClientResource, times(1)).update(); + } + + // ---- create ------------------------------------------------------------- + + @Test + void createDefaultForceFiltersAndCallsClientCreate() { + var resource = TestUtils.testCustomResource1(); + var updated = wireVerbMocks(); + + var result = resourceOperations.create(resource); + + assertThat(result).isSameAs(updated); + verify(verbEventSource, times(1)) + .eventFilteringUpdateAndCacheResource(eq(resource), any(UnaryOperator.class)); + verify(verbEventSource, never()).updateAndCacheResource(any(), any(UnaryOperator.class)); + verify(verbClientResource, times(1)).create(); + } + + @Test + void createCacheOnlyCachesAndCallsClientCreate() { + var resource = TestUtils.testCustomResource1(); + var updated = wireVerbMocks(); + + var result = resourceOperations.create(resource, ResourceOperations.Options.cacheOnly()); + + assertThat(result).isSameAs(updated); + verify(verbEventSource, times(1)) + .updateAndCacheResource(eq(resource), any(UnaryOperator.class)); + verify(verbEventSource, never()) + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + verify(verbClientResource, times(1)).create(); + } + + @Test + void createWithNullEventSourceFallsBackToForceFilter() { + var resource = TestUtils.testCustomResource1(); + wireVerbMocks(); + + // a null informer event source falls back to the default create(), which force-filters + resourceOperations.create(resource, null, ResourceOperations.Options.cacheOnly()); + + verify(verbEventSource, times(1)) + .eventFilteringUpdateAndCacheResource(eq(resource), any(UnaryOperator.class)); + verify(verbEventSource, never()).updateAndCacheResource(any(), any(UnaryOperator.class)); + } + + // ---- patch / status verbs ---------------------------------------------- + + @Test + void jsonMergePatchCacheOnlyCallsClientPatch() { + var resource = TestUtils.testCustomResource1(); + var updated = wireVerbMocks(); + + var result = + resourceOperations.jsonMergePatch(resource, ResourceOperations.Options.cacheOnly()); + + assertThat(result).isSameAs(updated); + verify(verbEventSource, times(1)) + .updateAndCacheResource(eq(resource), any(UnaryOperator.class)); + verify(verbClientResource, times(1)).patch(); + } + + @Test + void serverSideApplyCacheOnlyCallsClientSsaPatch() { + var resource = TestUtils.testCustomResource1(); + var updated = wireVerbMocks(); + + var result = + resourceOperations.serverSideApply(resource, ResourceOperations.Options.cacheOnly()); + + assertThat(result).isSameAs(updated); + verify(verbEventSource, times(1)) + .updateAndCacheResource(eq(resource), any(UnaryOperator.class)); + verify(verbClientResource, times(1)).patch(any(PatchContext.class)); + } + + @Test + void updateStatusCacheOnlyCallsClientUpdateStatus() { + var resource = TestUtils.testCustomResource1(); + var updated = wireVerbMocks(); + + var result = resourceOperations.updateStatus(resource, ResourceOperations.Options.cacheOnly()); + + assertThat(result).isSameAs(updated); + verify(verbEventSource, times(1)) + .updateAndCacheResource(eq(resource), any(UnaryOperator.class)); + verify(verbClientResource, times(1)).updateStatus(); + } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomReconciler.java index a4a2f6398a..0d274c5fef 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceTestCustomReconciler.java @@ -43,14 +43,13 @@ public UpdateControl reconcile( numberOfExecutions.addAndGet(1); log.info("Value: " + resource.getSpec().getValue()); - ensureStatusExists(resource); - waitXms(RECONCILER_MIN_EXEC_TIME); context .resourceOperations() .jsonPatchPrimaryStatus( resource, r -> { + ensureStatusExists(r); r.getStatus().setState(SubResourceTestCustomResourceStatus.State.SUCCESS); return r; }); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceUpdateIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceUpdateIT.java index 1ea9ca96ce..a86220439c 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceUpdateIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/subresource/SubResourceUpdateIT.java @@ -59,7 +59,7 @@ void updatesSubResourceStatus() { // wait for sure, there are no more events waitXms(WAIT_AFTER_EXECUTION); // there is no event on status update processed - assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(2); + assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(1); } @Test @@ -73,7 +73,7 @@ void updatesSubResourceStatusNoFinalizer() { // wait for sure, there are no more events waitXms(WAIT_AFTER_EXECUTION); // there is no event on status update processed - assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(2); + assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(1); } /** Note that we check on controller impl if there is finalizer on execution. */ @@ -87,7 +87,7 @@ void ifNoFinalizerPresentFirstAddsTheFinalizerThenExecutesControllerAgain() { // wait for sure, there are no more events waitXms(WAIT_AFTER_EXECUTION); // there is no event on status update processed - assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(2); + assertThat(TestUtils.getNumberOfExecutions(operator)).isEqualTo(1); } /**