Skip to content

Commit f26ad07

Browse files
committed
wip
Signed-off-by: Attila Mészáros <a_meszaros@apple.com>
1 parent fec20a2 commit f26ad07

File tree

6 files changed

+556
-0
lines changed

6 files changed

+556
-0
lines changed

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ReconcileUtils.java

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,4 +603,57 @@ public static <P extends HasMetadata> P addFinalizerWithSSA(
603603
e);
604604
}
605605
}
606+
607+
/**
608+
* Returns a collector that deduplicates Kubernetes objects by keeping only the one with the
609+
* latest metadata.resourceVersion for each unique name and namespace combination. The intended
610+
* use case is for the rather rare setup when there are overlapping {@link
611+
* io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource}s for a
612+
* resource type.
613+
*
614+
* @param <T> the type of HasMetadata objects
615+
* @return a collector that produces a collection of deduplicated Kubernetes objects
616+
*/
617+
public static <T extends HasMetadata> Collector<T, ?, Collection<T>> latestDistinct() {
618+
return Collectors.collectingAndThen(latestDistinctToMap(), Map::values);
619+
}
620+
621+
/**
622+
* Returns a collector that deduplicates Kubernetes objects by keeping only the one with the
623+
* latest metadata.resourceVersion for each unique name and namespace combination. The intended
624+
* use case is for the rather rare setup when there are overlapping {@link
625+
* io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource}s for a
626+
* resource type.
627+
*
628+
* @param <T> the type of HasMetadata objects
629+
* @return a collector that produces a List of deduplicated Kubernetes objects
630+
*/
631+
public static <T extends HasMetadata> Collector<T, ?, List<T>> latestDistinctList() {
632+
return Collectors.collectingAndThen(
633+
latestDistinctToMap(), map -> new ArrayList<>(map.values()));
634+
}
635+
636+
/**
637+
* Returns a collector that deduplicates Kubernetes objects by keeping only the one with the
638+
* latest metadata.resourceVersion for each unique name and namespace combination. The intended
639+
* use case is for the rather rare setup when there are overlapping {@link
640+
* io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource}s for a
641+
* resource type.
642+
*
643+
* @param <T> the type of HasMetadata objects
644+
* @return a collector that produces a Set of deduplicated Kubernetes objects
645+
*/
646+
public static <T extends HasMetadata> Collector<T, ?, Set<T>> latestDistinctSet() {
647+
return Collectors.collectingAndThen(latestDistinctToMap(), map -> new HashSet<>(map.values()));
648+
}
649+
650+
private static <T extends HasMetadata> Collector<T, ?, Map<ResourceID, T>> latestDistinctToMap() {
651+
return Collectors.toMap(
652+
resource ->
653+
new ResourceID(resource.getMetadata().getName(), resource.getMetadata().getNamespace()),
654+
resource -> resource,
655+
(existing, replacement) ->
656+
compareResourceVersions(existing, replacement) >= 0 ? existing : replacement);
657+
}
658+
606659
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
/*
2+
* Copyright Java Operator SDK Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.javaoperatorsdk.operator.baseapi.latestdistinct;
17+
18+
import java.time.Duration;
19+
import java.util.HashMap;
20+
import java.util.Map;
21+
22+
import org.junit.jupiter.api.Test;
23+
import org.junit.jupiter.api.extension.RegisterExtension;
24+
25+
import io.fabric8.kubernetes.api.model.ConfigMap;
26+
import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
27+
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
28+
import io.javaoperatorsdk.annotation.Sample;
29+
import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension;
30+
31+
import static io.javaoperatorsdk.operator.baseapi.latestdistinct.LatestDistinctTestReconciler.LABEL_KEY;
32+
import static io.javaoperatorsdk.operator.baseapi.latestdistinct.LatestDistinctTestReconciler.LABEL_TYPE_1;
33+
import static io.javaoperatorsdk.operator.baseapi.latestdistinct.LatestDistinctTestReconciler.LABEL_TYPE_2;
34+
import static org.assertj.core.api.Assertions.assertThat;
35+
import static org.awaitility.Awaitility.await;
36+
37+
@Sample(
38+
tldr = "Latest Distinct with Multiple InformerEventSources",
39+
description =
40+
"""
41+
Demonstrates using two separate InformerEventSource instances for ConfigMaps with \
42+
overlapping watches, combined with latestDistinctList() to deduplicate resources by \
43+
keeping the latest version. Also tests ReconcileUtils methods for patching resources \
44+
with proper cache updates.
45+
""")
46+
class LatestDistinctIT {
47+
48+
public static final String TEST_RESOURCE_NAME = "test-resource";
49+
public static final String CONFIG_MAP_1 = "config-map-1";
50+
public static final String CONFIG_MAP_2 = "config-map-2";
51+
public static final String CONFIG_MAP_3 = "config-map-3";
52+
53+
@RegisterExtension
54+
LocallyRunOperatorExtension operator =
55+
LocallyRunOperatorExtension.builder()
56+
.withReconciler(LatestDistinctTestReconciler.class)
57+
.build();
58+
59+
@Test
60+
void testLatestDistinctListWithTwoInformerEventSources() {
61+
// Create the custom resource
62+
var resource = createTestCustomResource();
63+
operator.create(resource);
64+
65+
// Create ConfigMaps with type1 label (watched by first event source)
66+
var cm1 = createConfigMap(CONFIG_MAP_1, LABEL_TYPE_1, resource, "value1");
67+
operator.create(cm1);
68+
69+
var cm2 = createConfigMap(CONFIG_MAP_2, LABEL_TYPE_1, resource, "value2");
70+
operator.create(cm2);
71+
72+
// Create ConfigMap with type2 label (watched by second event source)
73+
var cm3 = createConfigMap(CONFIG_MAP_3, LABEL_TYPE_2, resource, "value3");
74+
operator.create(cm3);
75+
76+
// Wait for reconciliation
77+
var reconciler = operator.getReconcilerOfType(LatestDistinctTestReconciler.class);
78+
await()
79+
.atMost(Duration.ofSeconds(5))
80+
.pollDelay(Duration.ofMillis(300))
81+
.untilAsserted(
82+
() -> {
83+
assertThat(reconciler.getNumberOfExecutions()).isGreaterThanOrEqualTo(1);
84+
var updatedResource =
85+
operator.get(LatestDistinctTestResource.class, TEST_RESOURCE_NAME);
86+
assertThat(updatedResource.getStatus()).isNotNull();
87+
// Should see 3 distinct ConfigMaps
88+
assertThat(updatedResource.getStatus().getConfigMapCount()).isEqualTo(3);
89+
assertThat(updatedResource.getStatus().getDataFromConfigMaps())
90+
.isEqualTo("value1,value2,value3");
91+
// Verify ReconcileUtils was used
92+
assertThat(updatedResource.getStatus().isReconcileUtilsCalled()).isTrue();
93+
});
94+
95+
// Verify distinct ConfigMap names
96+
assertThat(reconciler.getDistinctConfigMapNames())
97+
.containsExactlyInAnyOrder(CONFIG_MAP_1, CONFIG_MAP_2, CONFIG_MAP_3);
98+
}
99+
100+
@Test
101+
void testLatestDistinctDeduplication() {
102+
// Create the custom resource
103+
var resource = createTestCustomResource();
104+
operator.create(resource);
105+
106+
// Create a ConfigMap with type1 label
107+
var cm1 = createConfigMap(CONFIG_MAP_1, LABEL_TYPE_1, resource, "initialValue");
108+
operator.create(cm1);
109+
110+
// Wait for initial reconciliation
111+
var reconciler = operator.getReconcilerOfType(LatestDistinctTestReconciler.class);
112+
await()
113+
.atMost(Duration.ofSeconds(5))
114+
.pollDelay(Duration.ofMillis(300))
115+
.untilAsserted(
116+
() -> {
117+
var updatedResource =
118+
operator.get(LatestDistinctTestResource.class, TEST_RESOURCE_NAME);
119+
assertThat(updatedResource.getStatus()).isNotNull();
120+
assertThat(updatedResource.getStatus().getConfigMapCount()).isEqualTo(1);
121+
assertThat(updatedResource.getStatus().getDataFromConfigMaps())
122+
.isEqualTo("initialValue");
123+
});
124+
125+
int executionsBeforeUpdate = reconciler.getNumberOfExecutions();
126+
127+
// Update the ConfigMap
128+
cm1 = operator.get(ConfigMap.class, CONFIG_MAP_1);
129+
cm1.getData().put("key", "updatedValue");
130+
operator.replace(cm1);
131+
132+
// Wait for reconciliation after update
133+
await()
134+
.atMost(Duration.ofSeconds(5))
135+
.pollDelay(Duration.ofMillis(300))
136+
.untilAsserted(
137+
() -> {
138+
assertThat(reconciler.getNumberOfExecutions()).isGreaterThan(executionsBeforeUpdate);
139+
var updatedResource =
140+
operator.get(LatestDistinctTestResource.class, TEST_RESOURCE_NAME);
141+
assertThat(updatedResource.getStatus()).isNotNull();
142+
// Still should see only 1 distinct ConfigMap (same name, updated version)
143+
assertThat(updatedResource.getStatus().getConfigMapCount()).isEqualTo(1);
144+
assertThat(updatedResource.getStatus().getDataFromConfigMaps())
145+
.isEqualTo("updatedValue");
146+
});
147+
}
148+
149+
@Test
150+
void testReconcileUtilsServerSideApply() {
151+
// Create the custom resource with initial spec value
152+
var resource = createTestCustomResource();
153+
resource.getSpec().setValue("initialSpecValue");
154+
operator.create(resource);
155+
156+
// Create a ConfigMap
157+
var cm1 = createConfigMap(CONFIG_MAP_1, LABEL_TYPE_1, resource, "value1");
158+
operator.create(cm1);
159+
160+
// Wait for reconciliation
161+
var reconciler = operator.getReconcilerOfType(LatestDistinctTestReconciler.class);
162+
await()
163+
.atMost(Duration.ofSeconds(5))
164+
.pollDelay(Duration.ofMillis(300))
165+
.untilAsserted(
166+
() -> {
167+
var updatedResource =
168+
operator.get(LatestDistinctTestResource.class, TEST_RESOURCE_NAME);
169+
assertThat(updatedResource.getStatus()).isNotNull();
170+
assertThat(updatedResource.getStatus().isReconcileUtilsCalled()).isTrue();
171+
// Verify that the status was updated using ReconcileUtils.serverSideApplyStatus
172+
assertThat(updatedResource.getStatus().getConfigMapCount()).isEqualTo(1);
173+
});
174+
175+
// Verify no errors occurred
176+
assertThat(reconciler.isErrorOccurred()).isFalse();
177+
}
178+
179+
private LatestDistinctTestResource createTestCustomResource() {
180+
var resource = new LatestDistinctTestResource();
181+
resource.setMetadata(
182+
new ObjectMetaBuilder()
183+
.withName(TEST_RESOURCE_NAME)
184+
.withNamespace(operator.getNamespace())
185+
.build());
186+
resource.setSpec(new LatestDistinctTestResourceSpec());
187+
return resource;
188+
}
189+
190+
private ConfigMap createConfigMap(
191+
String name, String labelValue, LatestDistinctTestResource owner, String dataValue) {
192+
Map<String, String> labels = new HashMap<>();
193+
labels.put(LABEL_KEY, labelValue);
194+
195+
Map<String, String> data = new HashMap<>();
196+
data.put("key", dataValue);
197+
198+
return new ConfigMapBuilder()
199+
.withMetadata(
200+
new ObjectMetaBuilder()
201+
.withName(name)
202+
.withNamespace(operator.getNamespace())
203+
.withLabels(labels)
204+
.build())
205+
.withData(data)
206+
.withNewMetadata()
207+
.withName(name)
208+
.withNamespace(operator.getNamespace())
209+
.withLabels(labels)
210+
.addNewOwnerReference()
211+
.withApiVersion(owner.getApiVersion())
212+
.withKind(owner.getKind())
213+
.withName(owner.getMetadata().getName())
214+
.withUid(owner.getMetadata().getUid())
215+
.endOwnerReference()
216+
.endMetadata()
217+
.build();
218+
}
219+
}

0 commit comments

Comments
 (0)