diff --git a/docs/content/en/docs/documentation/eventing.md b/docs/content/en/docs/documentation/eventing.md index 06b8ccf9e9..d2a104737b 100644 --- a/docs/content/en/docs/documentation/eventing.md +++ b/docs/content/en/docs/documentation/eventing.md @@ -139,6 +139,14 @@ 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. +On an update event, the SDK calls `toPrimaryResourceIDs` for **both the old and the new version** +of the secondary resource. This way it can reconcile not only the primaries that the secondary +currently maps to, but also those it previously mapped to and no longer does. So when a reference +changes — including when only a subset of the referenced primaries changes — both the newly +referenced and the dropped primaries are reconciled, and a dropped primary can revert to its +default state. Because the mapper can be invoked for an older version of a resource, keep your +implementation a pure function of the resource passed to it. + 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..d1f79a7981 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,15 @@ */ @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. + * + * @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. On update events, this method is invoked + * for both the old and the new versions of the resource. */ Set toPrimaryResourceIDs(R resource); } 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..2ec8aa9372 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 @@ -32,18 +32,42 @@ public DefaultPrimaryToSecondaryIndex(SecondaryToPrimaryMapper secondaryToPri } @Override - public synchronized void onAddOrUpdate(R resource) { + public synchronized Set onAddOrUpdate(R resource, R oldResource) { + Set primaryResources = secondaryToPrimaryMapper.toPrimaryResourceIDs(resource); + + var secondaryId = ResourceID.fromResource(resource); + primaryResources.forEach( primaryResource -> { var resourceSet = index.computeIfAbsent(primaryResource, pr -> ConcurrentHashMap.newKeySet()); - resourceSet.add(ResourceID.fromResource(resource)); + resourceSet.add(secondaryId); }); + + if (oldResource != null) { + var obsoletePrimaries = + new HashSet<>(secondaryToPrimaryMapper.toPrimaryResourceIDs(oldResource)); + if (!primaryResources.containsAll(obsoletePrimaries)) { + var result = new HashSet<>(primaryResources); + obsoletePrimaries.removeAll(primaryResources); + obsoletePrimaries.forEach( + p -> + index.computeIfPresent( + p, + (id, currentSet) -> { + currentSet.remove(secondaryId); + return currentSet.isEmpty() ? null : currentSet; + })); + result.addAll(obsoletePrimaries); + return result; + } + } + return primaryResources; } @Override - public synchronized void onDelete(R resource) { + public synchronized Set onDelete(R resource) { Set primaryResources = secondaryToPrimaryMapper.toPrimaryResourceIDs(resource); primaryResources.forEach( primaryResource -> { @@ -58,6 +82,7 @@ public synchronized void onDelete(R resource) { } } }); + return primaryResources; } @Override 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..30fd6a7fb5 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 @@ -15,6 +15,7 @@ */ package io.javaoperatorsdk.operator.processing.event.source.informer; +import java.util.HashSet; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -127,10 +128,10 @@ public synchronized void onDelete(R resource, boolean deletedFinalStateUnknown) if (resultEvent.isEmpty()) { return; } - primaryToSecondaryIndex.onDelete(resource); + var primaryIds = primaryToSecondaryIndex.onDelete(resource); if (eventAcceptedByFilter( ResourceAction.DELETED, resource, null, deletedFinalStateUnknown)) { - propagateEvent(resource); + propagateEvent(resource, null, primaryIds); } }); } @@ -144,11 +145,12 @@ protected void handleEvent( // onAdd/onUpdate/onDelete watch paths. The index is updated for DELETED regardless of the // filter outcome — the resource is really gone, so leaving a tombstone in the index would // make getSecondaryResources keep returning a stale entry. + Set primaryIds = null; if (action == ResourceAction.DELETED) { log.debug( "handleEvent: removing from primaryToSecondaryIndex. id={}", ResourceID.fromResource(resource)); - primaryToSecondaryIndex.onDelete(resource); + primaryIds = primaryToSecondaryIndex.onDelete(resource); } if (!eventAcceptedByFilter(action, resource, oldResource, deletedFinalStateUnknown)) { if (log.isDebugEnabled()) { @@ -166,7 +168,7 @@ protected void handleEvent( action, resource.getMetadata().getResourceVersion()); } - propagateEvent(resource); + propagateEvent(resource, oldResource, primaryIds); } @Override @@ -177,12 +179,12 @@ public synchronized void start() { super.start(); // this makes sure that on first reconciliation all resources are // present on the index - manager().list().forEach(primaryToSecondaryIndex::onAddOrUpdate); + manager().list().forEach(r -> primaryToSecondaryIndex.onAddOrUpdate(r, null)); } @SuppressWarnings("unchecked") private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R oldObject) { - primaryToSecondaryIndex.onAddOrUpdate(newObject); + var primaryIds = primaryToSecondaryIndex.onAddOrUpdate(newObject, oldObject); var resourceID = ResourceID.fromResource(newObject); var resultEvent = temporaryResourceCache.onAddOrUpdateEvent(action, newObject, oldObject); @@ -194,15 +196,22 @@ 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, primaryIds); } else { log.debug("Event filtered out for operation: {}, resourceID: {}", action, resourceID); } } - protected void propagateEvent(R object) { - var primaryResourceIdSet = - configuration().getSecondaryToPrimaryMapper().toPrimaryResourceIDs(object); + protected void propagateEvent(R resource, R oldResource, Set primaryResourceIdSet) { + if (primaryResourceIdSet == null) { + primaryResourceIdSet = new HashSet<>(); + primaryResourceIdSet.addAll( + configuration().getSecondaryToPrimaryMapper().toPrimaryResourceIDs(resource)); + if (oldResource != null) { + primaryResourceIdSet.addAll( + configuration().getSecondaryToPrimaryMapper().toPrimaryResourceIDs(oldResource)); + } + } if (primaryResourceIdSet.isEmpty()) { return; } @@ -249,16 +258,16 @@ public Set getSecondaryResources(P primary) { @Override public void handleRecentResourceUpdate( ResourceID resourceID, R resource, R previousVersionOfResource) { - handleRecentCreateOrUpdate(resource); + handleRecentCreateOrUpdate(resource, previousVersionOfResource); } @Override public void handleRecentResourceCreate(ResourceID resourceID, R resource) { - handleRecentCreateOrUpdate(resource); + handleRecentCreateOrUpdate(resource, null); } - private void handleRecentCreateOrUpdate(R newResource) { - primaryToSecondaryIndex.onAddOrUpdate(newResource); + private void handleRecentCreateOrUpdate(R newResource, R previousVersion) { + primaryToSecondaryIndex.onAddOrUpdate(newResource, previousVersion); temporaryResourceCache.putResource(newResource); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java index ce217a5543..b22b958fc2 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java @@ -33,13 +33,14 @@ public static NOOPPrimaryToSecondaryIndex getInstance private NOOPPrimaryToSecondaryIndex() {} @Override - public void onAddOrUpdate(R resource) { - // empty method because of noop implementation + public Set onAddOrUpdate(R resource, R oldResource) { + return null; } @Override - public void onDelete(R resource) { + public Set onDelete(R resource) { // empty method because of noop implementation + return null; } @Override diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndex.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndex.java index f88e481316..65fd692b25 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndex.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndex.java @@ -22,9 +22,9 @@ public interface PrimaryToSecondaryIndex { - void onAddOrUpdate(R resource); + Set onAddOrUpdate(R resource, R oldResource); - void onDelete(R resource); + Set onDelete(R resource); Set getSecondaryResources(ResourceID primary); } 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..3d9e8cbc1d 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 @@ -655,11 +655,13 @@ void handleEventUpdatesIndexWhenDeletePropagatesFromTempCache() throws Exception // and getSecondaryResources keeps returning a tombstone. var indexMock = injectIndexMock(); var resource = testDeployment(); + // onDelete now returns the primaries to reconcile; propagateEvent uses that set directly + when(indexMock.onDelete(resource)).thenReturn(Set.of(ResourceID.fromResource(resource))); informerEventSource.handleEvent(ResourceAction.DELETED, resource, null, false); verify(indexMock, times(1)).onDelete(resource); - verify(indexMock, never()).onAddOrUpdate(any()); + verify(indexMock, never()).onAddOrUpdate(any(), any()); verify(eventHandlerMock, times(1)).handleEvent(any()); } @@ -673,7 +675,7 @@ void handleEventDoesNotTouchIndexForNonDeleteAction() throws Exception { ResourceAction.UPDATED, testDeployment(), testDeployment(), null); verify(indexMock, never()).onDelete(any()); - verify(indexMock, never()).onAddOrUpdate(any()); + verify(indexMock, never()).onAddOrUpdate(any(), any()); verify(eventHandlerMock, times(1)).handleEvent(any()); } @@ -745,14 +747,17 @@ 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(), 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(), any())); } private void expectHandleUpdateEvent(int newResourceVersion, int oldResourceVersion) { 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..d298bce8bb 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 @@ -15,6 +15,7 @@ */ package io.javaoperatorsdk.operator.processing.event.source.informer; +import java.util.Map; import java.util.Set; import org.junit.jupiter.api.BeforeEach; @@ -27,6 +28,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -58,7 +60,7 @@ void returnsEmptySetOnEmptyIndex() { @Test void indexesNewResources() { - primaryToSecondaryIndex.onAddOrUpdate(secondary1); + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); var secondaryResources1 = primaryToSecondaryIndex.getSecondaryResources(primaryID1); var secondaryResources2 = primaryToSecondaryIndex.getSecondaryResources(primaryID2); @@ -69,8 +71,8 @@ void indexesNewResources() { @Test void indexesAdditionalResources() { - primaryToSecondaryIndex.onAddOrUpdate(secondary1); - primaryToSecondaryIndex.onAddOrUpdate(secondary2); + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); + primaryToSecondaryIndex.onAddOrUpdate(secondary2, null); var secondaryResources1 = primaryToSecondaryIndex.getSecondaryResources(primaryID1); var secondaryResources2 = primaryToSecondaryIndex.getSecondaryResources(primaryID2); @@ -83,8 +85,8 @@ void indexesAdditionalResources() { @Test void removingResourceFromIndex() { - primaryToSecondaryIndex.onAddOrUpdate(secondary1); - primaryToSecondaryIndex.onAddOrUpdate(secondary2); + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); + primaryToSecondaryIndex.onAddOrUpdate(secondary2, null); primaryToSecondaryIndex.onDelete(secondary1); var secondaryResources1 = primaryToSecondaryIndex.getSecondaryResources(primaryID1); @@ -102,6 +104,74 @@ void removingResourceFromIndex() { assertThat(secondaryResources2).isEmpty(); } + @Test + void updateRemovesObsoletePrimaryWhenReferenceNarrows() { + // initial version references both primaries (default stub) + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); + + // updated version references only primaryID1 + var updated = updatedVersionOf("secondary1"); + when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(eq(updated))) + .thenReturn(Set.of(primaryID1)); + primaryToSecondaryIndex.onAddOrUpdate(updated, secondary1); + + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID1)) + .containsOnly(ResourceID.fromResource(secondary1)); + // primaryID2 is no longer referenced, so its (now empty) entry is removed + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID2)).isEmpty(); + } + + @Test + void updateMovesSecondaryBetweenPrimaries() { + // initial version references only primaryID1 + when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(eq(secondary1))) + .thenReturn(Set.of(primaryID1)); + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); + + // updated version moves the reference to primaryID2 + var updated = updatedVersionOf("secondary1"); + when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(eq(updated))) + .thenReturn(Set.of(primaryID2)); + primaryToSecondaryIndex.onAddOrUpdate(updated, secondary1); + + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID1)).isEmpty(); + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID2)) + .containsOnly(ResourceID.fromResource(secondary1)); + } + + @Test + void updateOnlyRemovesUpdatedSecondaryFromObsoletePrimary() { + // two secondaries, each referencing both primaries (default stub) + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); + primaryToSecondaryIndex.onAddOrUpdate(secondary2, null); + + // secondary1 stops referencing primaryID2 + var updated = updatedVersionOf("secondary1"); + when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(eq(updated))) + .thenReturn(Set.of(primaryID1)); + primaryToSecondaryIndex.onAddOrUpdate(updated, secondary1); + + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID1)) + .containsOnly(ResourceID.fromResource(secondary1), ResourceID.fromResource(secondary2)); + // primaryID2 is still referenced by secondary2, so only secondary1 is removed from it + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID2)) + .containsOnly(ResourceID.fromResource(secondary2)); + } + + @Test + void updateKeepsIndexUnchangedWhenReferencedPrimariesDoNotChange() { + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); + + // updated version still references both primaries (default stub applies to it as well) + var updated = updatedVersionOf("secondary1"); + primaryToSecondaryIndex.onAddOrUpdate(updated, secondary1); + + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID1)) + .containsOnly(ResourceID.fromResource(secondary1)); + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID2)) + .containsOnly(ResourceID.fromResource(secondary1)); + } + ConfigMap secondary(String name) { ConfigMap configMap = new ConfigMap(); configMap.setMetadata(new ObjectMeta()); @@ -109,4 +179,14 @@ ConfigMap secondary(String name) { configMap.getMetadata().setNamespace("default"); return configMap; } + + /** + * Returns a new version of a secondary with the same {@link ResourceID} but a different content, + * so it represents an updated resource that the mapper mock can be stubbed for independently. + */ + ConfigMap updatedVersionOf(String name) { + ConfigMap configMap = secondary(name); + configMap.getMetadata().setLabels(Map.of("version", "updated")); + return configMap; + } } 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..f598017c4c --- /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 one or more {@link TargetCustomResource}s via {@code + * spec.targetNames} and serves as input for them. + */ +@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..9d2e5139e6 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigSpec.java @@ -0,0 +1,48 @@ +/* + * 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; + +public class ConfigSpec { + + /** + * Names of the {@link TargetCustomResource}s (in the same namespace) this config provides input + * for. A single config can reference multiple targets. + */ + private List targetNames; + + /** Value to be applied to the referenced targets' status. */ + private String value; + + public List getTargetNames() { + return targetNames; + } + + public ConfigSpec setTargetNames(List targetNames) { + this.targetNames = targetNames; + 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..72930261de --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java @@ -0,0 +1,48 @@ +/* + * 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.Set; +import java.util.stream.Collectors; + +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; + +/** + * Maps a {@link ConfigCustomResource} (secondary) to the {@link TargetCustomResource}s (primaries) + * it references via {@code spec.targetNames}. A config can reference multiple targets. + * + *

The mapper only reports the current references. When the referenced set changes — for + * example when a subset of the targets is replaced — the framework's primary-to-secondary index + * reconciles both the newly referenced targets and the ones that are no longer referenced, so a + * dropped target reverts to its default value. The mapper therefore does not need to know about the + * previous version of the resource. + */ +public class ConfigToTargetMapper implements SecondaryToPrimaryMapper { + + @Override + public Set toPrimaryResourceIDs(ConfigCustomResource config) { + var targetNames = config.getSpec().getTargetNames(); + if (targetNames == null || targetNames.isEmpty()) { + return Set.of(); + } + var namespace = config.getMetadata().getNamespace(); + return targetNames.stream() + .filter(name -> name != null && !name.isBlank()) + .map(name -> new ResourceID(name, namespace)) + .collect(Collectors.toSet()); + } +} 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..37ade3a174 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/SecondaryToPrimaryReferenceChangeIT.java @@ -0,0 +1,109 @@ +/* + * 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 java.util.List; + +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 References to Primaries Change", + description = + """ + Demonstrates a configuration custom resource (the secondary) that references multiple \ + target custom resources (the primaries) via a spec field and serves as their input. Each \ + 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 a change of the \ + referenced set where only a subset changes: a target that is dropped from the references \ + reverts to the default, a target that stays keeps the value, and a newly referenced target \ + picks it up. + """) +class SecondaryToPrimaryReferenceChangeIT { + + static final String TARGET_A = "target-a"; + static final String TARGET_B = "target-b"; + static final String TARGET_C = "target-c"; + 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 targetsTakeValueFromReferencingConfigAndHandleSubsetReferenceChange() { + operator.create(target(TARGET_A)); + operator.create(target(TARGET_B)); + operator.create(target(TARGET_C)); + + // With no config, all targets fall back to the default value. + awaitTargetValue(TARGET_A, DEFAULT_VALUE); + awaitTargetValue(TARGET_B, DEFAULT_VALUE); + awaitTargetValue(TARGET_C, DEFAULT_VALUE); + + // A config referencing targets A and B makes both take the config's value; C stays default. + var config = operator.create(config(TARGET_A, TARGET_B)); + awaitTargetValue(TARGET_A, CONFIG_VALUE); + awaitTargetValue(TARGET_B, CONFIG_VALUE); + awaitTargetValue(TARGET_C, DEFAULT_VALUE); + + // Change a subset of the references: drop A, keep B, add C. A reverts to the default, B keeps + // the value, and C now picks it up. + config.getSpec().setTargetNames(List.of(TARGET_B, TARGET_C)); + operator.replace(config); + + awaitTargetValue(TARGET_C, CONFIG_VALUE); + awaitTargetValue(TARGET_A, DEFAULT_VALUE); + awaitTargetValue(TARGET_B, CONFIG_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... targetNames) { + var config = new ConfigCustomResource(); + config.setMetadata(new ObjectMetaBuilder().withName(CONFIG_NAME).build()); + config.setSpec(new ConfigSpec().setTargetNames(List.of(targetNames)).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..ee8d11e9d4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java @@ -0,0 +1,78 @@ +/* + * 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.annotation.Sample; +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; + +@Sample( + tldr = "Reconciling Primaries Driven by a Referencing Secondary Custom Resource", + description = + """ + A configuration custom resource (the secondary) references one or more target custom \ + resources (the primaries) through a spec field and acts as their input. This reconciler \ + watches those config resources with an InformerEventSource and, on each reconciliation, \ + sets the target's value from the config that currently references it, falling back to a \ + default when none does. When a config's set of references changes — including when only a \ + subset of the referenced targets is replaced — the framework's primary-to-secondary index \ + reconciles both the newly referenced targets and the ones that are no longer referenced, so \ + a dropped target reverts to its default. + """) +@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()) + .build(); + + var ies = new InformerEventSource<>(configuration, context); + return List.of(ies); + } + + @Override + public UpdateControl reconcile( + TargetCustomResource target, Context context) { + + // The framework keeps the primary-to-secondary index up to date on reference changes, so a + // config is only associated with the target it currently references. We take the value from + // the referencing config, or fall back to the default when none references this target. + var value = + context + .getSecondaryResource(ConfigCustomResource.class) + .map(config -> config.getSpec().getValue()) + .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; + } +}