From 2aa0527e39eda55d7be11543ca992b4f1a274a4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 28 May 2026 13:45:42 +0200 Subject: [PATCH 1/6] improve: extend SecondaryToPrimaryMapper so it also gets the old resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This might needed in some corner cases where might help with optimizations to which resource to trigger. Signed-off-by: Attila Mészáros --- .../content/en/docs/documentation/eventing.md | 19 ++++++++ .../source/SecondaryToPrimaryMapper.java | 43 ++++++++++++++++++- .../DefaultPrimaryToSecondaryIndex.java | 6 ++- .../source/informer/InformerEventSource.java | 4 +- .../informer/InformerEventSourceTest.java | 2 +- .../event/source/informer/MappersTest.java | 9 ++-- .../informer/PrimaryToSecondaryIndexTest.java | 2 +- 7 files changed, 73 insertions(+), 12 deletions(-) diff --git a/docs/content/en/docs/documentation/eventing.md b/docs/content/en/docs/documentation/eventing.md index 06b8ccf9e9..107a8a7db2 100644 --- a/docs/content/en/docs/documentation/eventing.md +++ b/docs/content/en/docs/documentation/eventing.md @@ -139,6 +139,25 @@ rare corner cases. Returning an empty set means that the mapper considered the s resource event as irrelevant and the SDK will thus not trigger a reconciliation of the primary resource in that situation. +`SecondaryToPrimaryMapper` exposes two methods: + +- `toPrimaryResourceIDs(R resource)` — the original mapper. Implementing it is sufficient for + the vast majority of use cases. +- `toPrimaryResourceIDs(R newResource, R oldResource)` — a variant that is the one actually + invoked by the SDK on every secondary event. Its default implementation delegates to the + single-argument method, so existing mappers keep working unchanged. + +Override the two-argument variant only in edge cases where the set of primary resources to +reconcile depends on what changed between the previous and the new version of the secondary +resource (e.g. a reference that moved from one primary to another, where both primaries need +to be reconciled). **Use it with caution:** `oldResource` is sourced from the informer cache and +is only populated for genuine update events observed while the controller is already running. +On controller startup the cache is empty, so the initial events received for resources that +already exist in the cluster are delivered as adds with `oldResource == null` — even if those +resources had been updated before the operator came up. `oldResource` is also `null` for delete +events and for events triggered through the primary-to-secondary index. Implementations must +therefore handle a `null` `oldResource` gracefully. + Adding a `SecondaryToPrimaryMapper` is typically sufficient when there is a one-to-many relationship between primary and secondary resources. The secondary resources can be mapped to its primary owner, and this is enough information to also get these secondary resources from the `Context` diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java index 0c6126105c..6861b6592a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java @@ -26,9 +26,48 @@ */ @FunctionalInterface public interface SecondaryToPrimaryMapper { + /** - * @param resource - secondary - * @return set of primary resource IDs + * Maps a secondary resource to the set of primary resources that should be reconciled in + * response. Implementing this single-argument form is sufficient for the vast majority of use + * cases — prefer it unless you specifically need access to the previous version of the + * secondary resource (see {@link #toPrimaryResourceIDs(Object, Object)}). + * + * @param resource the secondary resource for which an event was received + * @return set of primary resource IDs to enqueue for reconciliation; an empty set means the + * event is irrelevant and no reconciliation is triggered */ Set toPrimaryResourceIDs(R resource); + + /** + * Variant invoked by the framework for every secondary resource event, providing both the new and + * the previous version of the resource (when available). The default implementation simply + * delegates to {@link #toPrimaryResourceIDs(Object)} and ignores {@code oldResource}, so existing + * mappers keep working unchanged. + * + *

Override this method only for edge cases where the set of primary resources to reconcile + * depends on what changed between the old and the new version of the secondary resource (for + * example, when a reference held by the secondary resource has moved from one primary to another + * and both primaries need to be reconciled). + * + *

Use with caution. {@code oldResource} is sourced from the informer cache + * and is therefore only populated for genuine update events observed while the controller is + * already running. In particular, when the controller starts up, the cache is empty and the + * initial events received for resources that already existed in the cluster are delivered as adds + * with {@code oldResource == null} (even if those resources had been updated previously). {@code + * oldResource} is also {@code null} for delete events and for events triggered through the + * primary-to-secondary index. + * + *

Implementations must therefore handle a {@code null} {@code oldResource} gracefully and not + * rely on it being present for correctness — overriding this method is intended for edge cases + * only. + * + * @param newResource the current version of the secondary resource + * @param oldResource the previous version of the secondary resource, or {@code null} if not + * available (see above) + * @return set of primary resource IDs to enqueue for reconciliation + */ + default Set toPrimaryResourceIDs(R newResource, R oldResource) { + return toPrimaryResourceIDs(newResource); + } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java index 2b4f3814b3..c9a99aadb0 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java @@ -33,7 +33,8 @@ public DefaultPrimaryToSecondaryIndex(SecondaryToPrimaryMapper secondaryToPri @Override public synchronized void onAddOrUpdate(R resource) { - Set primaryResources = secondaryToPrimaryMapper.toPrimaryResourceIDs(resource); + Set primaryResources = + secondaryToPrimaryMapper.toPrimaryResourceIDs(resource, null); primaryResources.forEach( primaryResource -> { var resourceSet = @@ -44,7 +45,8 @@ public synchronized void onAddOrUpdate(R resource) { @Override public synchronized void onDelete(R resource) { - Set primaryResources = secondaryToPrimaryMapper.toPrimaryResourceIDs(resource); + Set primaryResources = + secondaryToPrimaryMapper.toPrimaryResourceIDs(resource, null); primaryResources.forEach( primaryResource -> { var secondaryResources = index.get(primaryResource); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index c425a4d413..80f0f62b50 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -200,9 +200,9 @@ private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R ol } } - protected void propagateEvent(R object) { + private void propagateEvent(R resource, R oldResource) { var primaryResourceIdSet = - configuration().getSecondaryToPrimaryMapper().toPrimaryResourceIDs(object); + configuration().getSecondaryToPrimaryMapper().toPrimaryResourceIDs(resource, oldResource); if (primaryResourceIdSet.isEmpty()) { return; } 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 dda08a7c98..555ffec924 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 @@ -85,7 +85,7 @@ void setup() { SecondaryToPrimaryMapper secondaryToPrimaryMapper = mock(SecondaryToPrimaryMapper.class); when(informerEventSourceConfiguration.getSecondaryToPrimaryMapper()) .thenReturn(secondaryToPrimaryMapper); - when(secondaryToPrimaryMapper.toPrimaryResourceIDs(any())) + when(secondaryToPrimaryMapper.toPrimaryResourceIDs(any(), any())) .thenReturn(Set.of(ResourceID.fromResource(testDeployment()))); when(informerEventSourceConfiguration.getInformerConfig()).thenReturn(informerConfig); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/MappersTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/MappersTest.java index 8d08c4e8a0..8b4a9fbff7 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/MappersTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/MappersTest.java @@ -40,7 +40,8 @@ void secondaryToPrimaryMapperFromOwnerReference() { var secondary = getConfigMap(primary); secondary.addOwnerReference(primary); - var res = Mappers.fromOwnerReferences(TestCustomResource.class).toPrimaryResourceIDs(secondary); + var res = + Mappers.fromOwnerReferences(TestCustomResource.class).toPrimaryResourceIDs(secondary, null); assertThat(res).contains(ResourceID.fromResource(primary)); } @@ -65,7 +66,7 @@ void secondaryToPrimaryMapperFromOwnerReferenceWhereGroupIdIsEmpty() { .build(); secondary.addOwnerReference(primary); - var res = Mappers.fromOwnerReferences(ConfigMap.class).toPrimaryResourceIDs(secondary); + var res = Mappers.fromOwnerReferences(ConfigMap.class).toPrimaryResourceIDs(secondary, null); assertThat(res).contains(ResourceID.fromResource(primary)); } @@ -79,7 +80,7 @@ void secondaryToPrimaryMapperFromOwnerReferenceFiltersByType() { var res = Mappers.fromOwnerReferences(TestCustomResourceOtherV1.class) - .toPrimaryResourceIDs(secondary); + .toPrimaryResourceIDs(secondary, null); assertThat(res).isEmpty(); } @@ -103,7 +104,7 @@ void fromOwnerReferenceIgnoresVersionFromApiVersion() { HasMetadata.getGroup(TestCustomResource.class) + "/v2", HasMetadata.getKind(TestCustomResource.class), false) - .toPrimaryResourceIDs(secondary); + .toPrimaryResourceIDs(secondary, null); assertThat(res).contains(ResourceID.fromResource(primary)); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java index 91bca3708c..9503085a03 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java @@ -46,7 +46,7 @@ class PrimaryToSecondaryIndexTest { @BeforeEach void setup() { - when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(any())) + when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(any(), any())) .thenReturn(Set.of(primaryID1, primaryID2)); } From b6e1225573136623677b6cfc19b4ef05fca0a6a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 28 May 2026 13:50:27 +0200 Subject: [PATCH 2/6] format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../processing/event/source/SecondaryToPrimaryMapper.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java index 6861b6592a..be38dde4a7 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java @@ -30,12 +30,12 @@ public interface SecondaryToPrimaryMapper { /** * Maps a secondary resource to the set of primary resources that should be reconciled in * response. Implementing this single-argument form is sufficient for the vast majority of use - * cases — prefer it unless you specifically need access to the previous version of the - * secondary resource (see {@link #toPrimaryResourceIDs(Object, Object)}). + * cases — prefer it unless you specifically need access to the previous version of the secondary + * resource (see {@link #toPrimaryResourceIDs(Object, Object)}). * * @param resource the secondary resource for which an event was received - * @return set of primary resource IDs to enqueue for reconciliation; an empty set means the - * event is irrelevant and no reconciliation is triggered + * @return set of primary resource IDs to enqueue for reconciliation; an empty set means the event + * is irrelevant and no reconciliation is triggered */ Set toPrimaryResourceIDs(R resource); From 69eac3acfc89ec0652734bc2a098826c857d10f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 16 Jun 2026 14:47:35 +0200 Subject: [PATCH 3/6] javadoc improve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../processing/event/source/SecondaryToPrimaryMapper.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java index be38dde4a7..8424c423d5 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java @@ -60,7 +60,8 @@ public interface SecondaryToPrimaryMapper { * *

Implementations must therefore handle a {@code null} {@code oldResource} gracefully and not * rely on it being present for correctness — overriding this method is intended for edge cases - * only. + * only. Genericly speaking controller should also handle such change checking during + * reconciliation, so when controller starts and event is missed it is properly reconiled. * * @param newResource the current version of the secondary resource * @param oldResource the previous version of the secondary resource, or {@code null} if not From 4899f14fb21227c1adfc0ea3f7941cdde1b042fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 17 Jun 2026 13:59:07 +0200 Subject: [PATCH 4/6] naive IT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../ConfigCustomResource.java | 33 ++++++ .../ConfigSpec.java | 45 ++++++++ .../ConfigToTargetMapper.java | 57 ++++++++++ .../SecondaryToPrimaryReferenceChangeIT.java | 102 ++++++++++++++++++ .../TargetCustomResource.java | 35 ++++++ .../TargetReconciler.java | 76 +++++++++++++ .../TargetStatus.java | 30 ++++++ 7 files changed, 378 insertions(+) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigSpec.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/SecondaryToPrimaryReferenceChangeIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetStatus.java diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigCustomResource.java new file mode 100644 index 0000000000..5216547c3e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigCustomResource.java @@ -0,0 +1,33 @@ +/* + * 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.secondarytoprimaryreferencechange; + +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.Kind; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +/** + * Secondary resource that references a {@link TargetCustomResource} via {@code spec.targetName} and + * serves as input for it. + */ +@Group("sample.javaoperatorsdk") +@Version("v1") +@Kind("SecondaryToPrimaryRefConfig") +@ShortNames("s2pconfig") +public class ConfigCustomResource extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigSpec.java new file mode 100644 index 0000000000..eacd6f7843 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigSpec.java @@ -0,0 +1,45 @@ +/* + * 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.secondarytoprimaryreferencechange; + +public class ConfigSpec { + + /** + * Name of the {@link TargetCustomResource} (in the same namespace) this config provides input. + */ + private String targetName; + + /** Value to be applied to the referenced target's status. */ + private String value; + + public String getTargetName() { + return targetName; + } + + public ConfigSpec setTargetName(String targetName) { + this.targetName = targetName; + return this; + } + + public String getValue() { + return value; + } + + public ConfigSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java new file mode 100644 index 0000000000..342e85fe7f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java @@ -0,0 +1,57 @@ +/* + * 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.secondarytoprimaryreferencechange; + +import java.util.HashSet; +import java.util.Set; + +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; + +/** + * Maps a {@link ConfigCustomResource} (secondary) to the {@link TargetCustomResource} (primary) it + * references via {@code spec.targetName}. + * + *

The interesting case is handling a reference change: when {@code spec.targetName} is + * edited to point from one target to another, both targets must be reconciled — the previously + * referenced one so it can fall back to its default value, and the newly referenced one so it can + * pick up the config's value. The single-argument mapper only knows about the new reference, so it + * would only enqueue the new target, leaving the old target with a stale value. By overriding the + * two-argument variant we additionally enqueue the old target whenever the reference moved. + */ +public class ConfigToTargetMapper implements SecondaryToPrimaryMapper { + + @Override + public Set toPrimaryResourceIDs(ConfigCustomResource config) { + var targetName = config.getSpec().getTargetName(); + if (targetName == null || targetName.isBlank()) { + return Set.of(); + } + return Set.of(new ResourceID(targetName, config.getMetadata().getNamespace())); + } + + @Override + public Set toPrimaryResourceIDs( + ConfigCustomResource newConfig, ConfigCustomResource oldConfig) { + var result = new HashSet<>(toPrimaryResourceIDs(newConfig)); + // oldConfig is only populated for genuine update events while the controller is running; for + // adds, deletes and startup it is null and there is no previous reference to reconcile. + if (oldConfig != null) { + result.addAll(toPrimaryResourceIDs(oldConfig)); + } + return result; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/SecondaryToPrimaryReferenceChangeIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/SecondaryToPrimaryReferenceChangeIT.java new file mode 100644 index 0000000000..288e6e6651 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/SecondaryToPrimaryReferenceChangeIT.java @@ -0,0 +1,102 @@ +/* + * 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.secondarytoprimaryreferencechange; + +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.annotation.Sample; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.secondarytoprimaryreferencechange.TargetReconciler.DEFAULT_VALUE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@Sample( + tldr = "Handling a Secondary Resource Whose Reference to a Primary Changes", + description = + """ + Demonstrates a configuration custom resource (the secondary) that references a target \ + custom resource (the primary) via a spec field and serves as input for it. The target is \ + reconciled so that, if a config references it, it takes the value from that config; \ + otherwise it falls back to a default. The test shows how to handle the config's reference \ + changing from one target to another: a SecondaryToPrimaryMapper that overrides the \ + two-argument variant enqueues both the previously referenced target (so it reverts to the \ + default) and the newly referenced one (so it picks up the value). + """) +class SecondaryToPrimaryReferenceChangeIT { + + static final String TARGET_A = "target-a"; + static final String TARGET_B = "target-b"; + static final String CONFIG_NAME = "config"; + static final String CONFIG_VALUE = "value-from-config"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withAdditionalCustomResourceDefinition(ConfigCustomResource.class) + .withReconciler(new TargetReconciler()) + .build(); + + @Test + void targetTakesValueFromReferencingConfigAndHandlesReferenceChange() { + operator.create(target(TARGET_A)); + operator.create(target(TARGET_B)); + + // With no config, both targets fall back to the default value. + awaitTargetValue(TARGET_A, DEFAULT_VALUE); + awaitTargetValue(TARGET_B, DEFAULT_VALUE); + + // A config referencing target A makes A take the config's value; B stays on the default. + var config = operator.create(config(TARGET_A)); + awaitTargetValue(TARGET_A, CONFIG_VALUE); + awaitTargetValue(TARGET_B, DEFAULT_VALUE); + + // Moving the reference from A to B reconciles both: A reverts to the default and B picks it up. + config.getSpec().setTargetName(TARGET_B); + operator.replace(config); + + awaitTargetValue(TARGET_B, CONFIG_VALUE); + awaitTargetValue(TARGET_A, DEFAULT_VALUE); + } + + private void awaitTargetValue(String name, String expectedValue) { + await() + .atMost(Duration.ofSeconds(10)) + .untilAsserted( + () -> { + var target = operator.get(TargetCustomResource.class, name); + assertThat(target.getStatus()).isNotNull(); + assertThat(target.getStatus().getValue()).isEqualTo(expectedValue); + }); + } + + private TargetCustomResource target(String name) { + var target = new TargetCustomResource(); + target.setMetadata(new ObjectMetaBuilder().withName(name).build()); + return target; + } + + private ConfigCustomResource config(String targetName) { + var config = new ConfigCustomResource(); + config.setMetadata(new ObjectMetaBuilder().withName(CONFIG_NAME).build()); + config.setSpec(new ConfigSpec().setTargetName(targetName).setValue(CONFIG_VALUE)); + return config; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetCustomResource.java new file mode 100644 index 0000000000..b3bcae6ccb --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetCustomResource.java @@ -0,0 +1,35 @@ +/* + * 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.secondarytoprimaryreferencechange; + +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.Kind; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +/** + * Primary resource that is reconciled. Its desired status value is provided by a {@link + * ConfigCustomResource} that references it (see {@link TargetReconciler}); when no config + * references it, a default value is used. + */ +@Group("sample.javaoperatorsdk") +@Version("v1") +@Kind("SecondaryToPrimaryRefTarget") +@ShortNames("s2ptarget") +public class TargetCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java new file mode 100644 index 0000000000..8668695bb4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java @@ -0,0 +1,76 @@ +/* + * 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.secondarytoprimaryreferencechange; + +import java.util.List; + +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.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +/** + * Reconciles {@link TargetCustomResource}s. The desired status value comes from a {@link + * ConfigCustomResource} that references the target via {@code spec.targetName}; if no config + * references the target, {@link #DEFAULT_VALUE} is used. + * + *

{@link ConfigCustomResource}s are watched as secondary resources through an {@link + * InformerEventSource} configured with {@link ConfigToTargetMapper}, so changes to a config trigger + * reconciliation of the referenced target(s). + */ +@ControllerConfiguration +public class TargetReconciler implements Reconciler { + + public static final String DEFAULT_VALUE = "default"; + + @Override + public List> prepareEventSources( + EventSourceContext context) { + + var configuration = + InformerEventSourceConfiguration.from( + ConfigCustomResource.class, TargetCustomResource.class) + .withSecondaryToPrimaryMapper(new ConfigToTargetMapper()) + .withPrimaryToSecondaryMapper(p -> null) + .build(); + + var ies = new InformerEventSource<>(configuration, context); + return List.of(ies); + } + + @Override + public UpdateControl reconcile( + TargetCustomResource target, Context context) { + + // There may be a stale secondary mapping after a reference change (the primary-to-secondary + // index does not remove the old association), so we also verify here that the config actually + // references this target. This makes reconciliation robust regardless of how the event arrived. + var value = + context.getSecondaryResources(ConfigCustomResource.class).stream() + .filter( + config -> target.getMetadata().getName().equals(config.getSpec().getTargetName())) + .map(config -> config.getSpec().getValue()) + .findFirst() + .orElse(DEFAULT_VALUE); + + target.setStatus(new TargetStatus().setValue(value)); + return UpdateControl.patchStatus(target); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetStatus.java new file mode 100644 index 0000000000..3679813070 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetStatus.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.secondarytoprimaryreferencechange; + +public class TargetStatus { + + private String value; + + public String getValue() { + return value; + } + + public TargetStatus setValue(String value) { + this.value = value; + return this; + } +} From 35b64670bba2ad2ef77e636e02d419b031d27bf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 17 Jun 2026 14:33:12 +0200 Subject: [PATCH 5/6] fix 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/InformerEventSource.java | 8 ++++---- .../event/source/informer/InformerEventSourceTest.java | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index 80f0f62b50..74853e0afe 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -130,7 +130,7 @@ public synchronized void onDelete(R resource, boolean deletedFinalStateUnknown) primaryToSecondaryIndex.onDelete(resource); if (eventAcceptedByFilter( ResourceAction.DELETED, resource, null, deletedFinalStateUnknown)) { - propagateEvent(resource); + propagateEvent(resource, null); } }); } @@ -166,7 +166,7 @@ protected void handleEvent( action, resource.getMetadata().getResourceVersion()); } - propagateEvent(resource); + propagateEvent(resource, oldResource); } @Override @@ -194,13 +194,13 @@ private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R ol "Propagating event for {}, resource with same version not result of a our update.", action); var event = resultEvent.get(); - propagateEvent((R) event.getResource().orElseThrow()); + propagateEvent((R) event.getResource().orElseThrow(), oldObject); } else { log.debug("Event filtered out for operation: {}, resourceID: {}", action, resourceID); } } - private void propagateEvent(R resource, R oldResource) { + void propagateEvent(R resource, R oldResource) { var primaryResourceIdSet = configuration().getSecondaryToPrimaryMapper().toPrimaryResourceIDs(resource, oldResource); if (primaryResourceIdSet.isEmpty()) { 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 555ffec924..6ef19acd78 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 @@ -745,14 +745,16 @@ private void assertNoEventProduced() { await() .pollDelay(Duration.ofMillis(70)) .timeout(Duration.ofMillis(150)) - .untilAsserted(() -> verify(informerEventSource, never()).propagateEvent(any())); + .untilAsserted(() -> verify(informerEventSource, never()).propagateEvent(any(), any())); } private void expectPropagateEvent(Deployment newResourceVersion) { await() .atMost(Duration.ofSeconds(1)) .untilAsserted( - () -> verify(informerEventSource, times(1)).propagateEvent(newResourceVersion)); + () -> + verify(informerEventSource, times(1)) + .propagateEvent(eq(newResourceVersion), any())); } private void expectHandleUpdateEvent(int newResourceVersion, int oldResourceVersion) { From 435c53d256850f13f5632aaad25481d691cc694c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 17 Jun 2026 14:35:20 +0200 Subject: [PATCH 6/6] fix test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../secondarytoprimaryreferencechange/TargetReconciler.java | 1 - 1 file changed, 1 deletion(-) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java index 8668695bb4..fcc0eab68c 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java @@ -48,7 +48,6 @@ public List> prepareEventSources( InformerEventSourceConfiguration.from( ConfigCustomResource.class, TargetCustomResource.class) .withSecondaryToPrimaryMapper(new ConfigToTargetMapper()) - .withPrimaryToSecondaryMapper(p -> null) .build(); var ies = new InformerEventSource<>(configuration, context);