Skip to content

Commit e211983

Browse files
committed
test: cover informer retry after a CR deserialization problem
Adds an IT for the situation of ADR 002 where the resource that cannot be deserialized is already present when the informer lists on startup, the counterpart of MultiVersionCRDIT, which covers the watch case. With stopOnInformerErrorDuringStartup set to false the operator starts, but the informer is not retried: the exception handler installed by the informer pool declines a retry for deserialization errors and the Reflector of the fabric8 client then completes its stop future, so fixing the problem in the cluster while the operator runs does not bring the informer back. The test asserts that current behavior, so it fails once the informer does get retried; the log of the informer pool no longer promises a periodic retry in this case.
1 parent d4bd3da commit e211983

2 files changed

Lines changed: 165 additions & 0 deletions

File tree

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AbstractInformerPool.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,15 @@ public <R extends HasMetadata> void start(
170170
if (configurationService.stopOnInformerErrorDuringStartup()) {
171171
log.error("Informer startup error. Operator will be stopped. Informer: {}", informer, e);
172172
throw new OperatorException(e);
173+
} else if (ExceptionHandler.isDeserializationException(e)) {
174+
// the exception handler installed in createInformer declines a retry for these, and an
175+
// informer that is not retried is stopped for good, so don't promise a retry here
176+
log.error(
177+
"Informer startup error caused by a deserialization problem. The informer is stopped"
178+
+ " and won't be retried, the operator has to be restarted after the problem is"
179+
+ " fixed. Informer: {}",
180+
informer,
181+
e);
173182
} else {
174183
log.warn("Informer startup error. Will periodically retry. Informer: {}", informer, e);
175184
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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.multiversioncrd;
17+
18+
import java.time.Duration;
19+
20+
import org.junit.jupiter.api.Test;
21+
import org.junit.jupiter.api.extension.RegisterExtension;
22+
import org.slf4j.Logger;
23+
import org.slf4j.LoggerFactory;
24+
25+
import io.fabric8.kubernetes.api.model.ObjectMeta;
26+
import io.fabric8.kubernetes.client.informers.ExceptionHandler;
27+
import io.fabric8.kubernetes.client.informers.SharedIndexInformer;
28+
import io.javaoperatorsdk.annotation.Sample;
29+
import io.javaoperatorsdk.operator.api.config.InformerStoppedHandler;
30+
import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension;
31+
32+
import static org.assertj.core.api.Assertions.assertThat;
33+
import static org.awaitility.Awaitility.await;
34+
35+
/**
36+
* Companion of {@link MultiVersionCRDIT}: there the resource that cannot be deserialized shows up
37+
* while the informer is already watching, here it is already present when the informer lists the
38+
* resources on startup. Since {@code stopOnInformerErrorDuringStartup} is {@code false} the
39+
* operator starts anyway and logs that it will periodically retry the informer, so this test checks
40+
* whether such a retry really happens, i.e. whether an operator picks it up when the problem is
41+
* fixed in the cluster while it is running.
42+
*/
43+
@Sample(
44+
tldr = "Informer Retry After a Custom Resource Deserialization Problem",
45+
description =
46+
"""
47+
Shows what happens to an operator whose informer cannot deserialize an already existing \
48+
custom resource, the situation described in the "Multi Version Custom Resources \
49+
Deserialization Problem" ADR: a resource created as v2 is stored as v1 because there is \
50+
no conversion hook, so the reconciler watching v1 receives a String where its spec \
51+
declares an int. With stopOnInformerErrorDuringStartup set to false the operator still \
52+
starts, but the informer of the affected controller is stopped for good: the test \
53+
documents that removing the offending resource while the operator is running does not \
54+
bring the informer back, the operator has to be restarted.
55+
""")
56+
class MultiVersionCRDDeserializationRetryIT {
57+
58+
private static final Logger log =
59+
LoggerFactory.getLogger(MultiVersionCRDDeserializationRetryIT.class);
60+
61+
public static final String NOT_DESERIALIZABLE_CR_NAME = "not-deserializable";
62+
public static final String VALID_CR_NAME = "valid";
63+
64+
private final CapturingInformerStoppedHandler informerStoppedHandler =
65+
new CapturingInformerStoppedHandler();
66+
67+
@RegisterExtension
68+
LocallyRunOperatorExtension operator =
69+
LocallyRunOperatorExtension.builder()
70+
// only the reconciler for v1 is registered, it watches the resources without a "version"
71+
// label, thus also the one created below as v2
72+
.withReconciler(new MultiVersionCRDTestReconciler1())
73+
.withConfigurationService(
74+
overrider ->
75+
overrider
76+
.withStopOnInformerErrorDuringStartup(false)
77+
.withInformerStoppedHandler(informerStoppedHandler))
78+
// v1 is the stored version and there is no conversion hook, so this resource is stored as
79+
// it was sent: with a String in the field that v1 declares as an int. The informer of the
80+
// v1 controller therefore already fails to deserialize it while listing on startup.
81+
.withBeforeStartHook(extension -> extension.create(notDeserializableResource()))
82+
.build();
83+
84+
@Test
85+
void informerIsNotRetriedAfterTheProblemIsFixedInTheCluster() {
86+
await()
87+
.atMost(Duration.ofSeconds(30))
88+
.untilAsserted(() -> assertThat(informerStoppedHandler.getError()).isNotNull());
89+
assertThat(ExceptionHandler.isDeserializationException(informerStoppedHandler.getError()))
90+
.isTrue();
91+
assertThat(operator.getOperator().getRuntimeInfo().allEventSourcesAreHealthy()).isFalse();
92+
93+
// the problem is fixed while the operator is running: the resource that cannot be deserialized
94+
// is removed. It is deleted through the v2 endpoint, where it can be deserialized.
95+
operator.delete(notDeserializableResource());
96+
await()
97+
.untilAsserted(
98+
() ->
99+
assertThat(
100+
operator.get(
101+
MultiVersionCRDTestCustomResource2.class, NOT_DESERIALIZABLE_CR_NAME))
102+
.isNull());
103+
operator.create(validResource());
104+
105+
// Nothing is reconciled: a deserialization error is excluded from the informer retries (see
106+
// AbstractInformerPool#createInformer) and the Reflector of the fabric8 client completes its
107+
// stop future as soon as its exception handler declines a retry, so the operator never notices
108+
// that the cluster is in order again. If this assertion starts to fail because the resource got
109+
// reconciled, the informer is retried after all: turn the assertions around, that is the
110+
// behavior we want.
111+
await()
112+
.pollDelay(Duration.ofSeconds(5))
113+
.atMost(Duration.ofSeconds(20))
114+
.untilAsserted(
115+
() -> {
116+
var actual = operator.get(MultiVersionCRDTestCustomResource1.class, VALID_CR_NAME);
117+
assertThat(actual).isNotNull();
118+
assertThat(actual.getStatus()).isNull();
119+
});
120+
assertThat(operator.getOperator().getRuntimeInfo().allEventSourcesAreHealthy()).isFalse();
121+
}
122+
123+
static MultiVersionCRDTestCustomResource2 notDeserializableResource() {
124+
var cr = new MultiVersionCRDTestCustomResource2();
125+
cr.setMetadata(new ObjectMeta());
126+
cr.getMetadata().setName(NOT_DESERIALIZABLE_CR_NAME);
127+
cr.setSpec(new MultiVersionCRDTestCustomResourceSpec2());
128+
cr.getSpec().setValue("string value");
129+
return cr;
130+
}
131+
132+
static MultiVersionCRDTestCustomResource1 validResource() {
133+
var cr = new MultiVersionCRDTestCustomResource1();
134+
cr.setMetadata(new ObjectMeta());
135+
cr.getMetadata().setName(VALID_CR_NAME);
136+
cr.setSpec(new MultiVersionCRDTestCustomResourceSpec1());
137+
cr.getSpec().setValue(1);
138+
return cr;
139+
}
140+
141+
private static class CapturingInformerStoppedHandler implements InformerStoppedHandler {
142+
143+
private volatile Throwable error;
144+
145+
@Override
146+
@SuppressWarnings("rawtypes")
147+
public void onStop(SharedIndexInformer informer, Throwable ex) {
148+
log.info("Informer for {} stopped", informer.getApiTypeClass().getName(), ex);
149+
error = ex;
150+
}
151+
152+
Throwable getError() {
153+
return error;
154+
}
155+
}
156+
}

0 commit comments

Comments
 (0)