Skip to content

Commit 86c9ef3

Browse files
authored
fix: shut down operator when failing to start (#1138)
* fix: start timer as daemon to avoid prolonging the app's life * fix: exit operator on exception when starting Fixes #1136 * feat: also release snapshots for v2 branch * fix: ignore Sonar false-positive issue * feat: extract missing CRD exception check to ReconcilerUtils Fixes #1139 * fix: propagate exceptions and stop on start exception * fix: add MockKubernetesClient to properly mock client * chore: regexp input is under control so marking as false positive
1 parent 4bb1eed commit 86c9ef3

File tree

12 files changed

+189
-46
lines changed

12 files changed

+189
-46
lines changed

.github/workflows/snapshot-releases.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ concurrency:
88
cancel-in-progress: true
99
on:
1010
push:
11-
branches: [ main, v1, next ]
11+
branches: [ main, v1, v2, next ]
1212
workflow_dispatch:
1313
jobs:
1414
test:

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/Operator.java

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -66,20 +66,26 @@ public List<Controller> getControllers() {
6666
* and start the cluster monitoring processes.
6767
*/
6868
public void start() {
69-
controllers.shouldStart();
70-
71-
final var version = configurationService.getVersion();
72-
log.info(
73-
"Operator SDK {} (commit: {}) built on {} starting...",
74-
version.getSdkVersion(),
75-
version.getCommit(),
76-
version.getBuiltTime());
77-
78-
final var clientVersion = Version.clientVersion();
79-
log.info("Client version: {}", clientVersion);
80-
81-
ExecutorServiceManager.init(configurationService);
82-
controllers.start();
69+
try {
70+
controllers.shouldStart();
71+
72+
final var version = configurationService.getVersion();
73+
log.info(
74+
"Operator SDK {} (commit: {}) built on {} starting...",
75+
version.getSdkVersion(),
76+
version.getCommit(),
77+
version.getBuiltTime());
78+
79+
final var clientVersion = Version.clientVersion();
80+
log.info("Client version: {}", clientVersion);
81+
82+
ExecutorServiceManager.init(configurationService);
83+
controllers.start();
84+
} catch (Exception e) {
85+
log.error("Error starting operator", e);
86+
stop();
87+
throw e;
88+
}
8389
}
8490

8591
@Override
@@ -166,10 +172,6 @@ public synchronized void start() {
166172
}
167173

168174
public synchronized void stop() {
169-
if (!started) {
170-
return;
171-
}
172-
173175
this.controllers.values().parallelStream().forEach(closeable -> {
174176
log.debug("closing {}", closeable);
175177
closeable.stop();
@@ -178,6 +180,7 @@ public synchronized void stop() {
178180
started = false;
179181
}
180182

183+
@SuppressWarnings("unchecked")
181184
public synchronized void add(Controller controller) {
182185
final var configuration = controller.getConfiguration();
183186
final var resourceTypeName = ReconcilerUtils

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/ReconcilerUtils.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
package io.javaoperatorsdk.operator;
22

3+
import java.util.Arrays;
34
import java.util.Locale;
5+
import java.util.function.Predicate;
6+
import java.util.regex.Pattern;
7+
import java.util.stream.Collectors;
48

59
import io.fabric8.kubernetes.api.model.HasMetadata;
610
import io.fabric8.kubernetes.api.model.ObjectMeta;
11+
import io.fabric8.kubernetes.client.KubernetesClientException;
712
import io.javaoperatorsdk.operator.api.reconciler.Constants;
813
import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration;
914
import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
@@ -98,4 +103,49 @@ public static String getDefaultReconcilerName(String reconcilerClassName) {
98103
}
99104
return reconcilerClassName.toLowerCase(Locale.ROOT);
100105
}
106+
107+
public static void handleKubernetesClientException(Exception e, String resourceTypeName) {
108+
if (e instanceof MissingCRDException) {
109+
throw ((MissingCRDException) e);
110+
}
111+
112+
if (e instanceof KubernetesClientException) {
113+
KubernetesClientException ke = (KubernetesClientException) e;
114+
if (404 == ke.getCode()) {
115+
// only throw MissingCRDException if the 404 error occurs on the target CRD
116+
if (resourceTypeName.equals(ke.getFullResourceName())
117+
|| matchesResourceType(resourceTypeName, ke)) {
118+
throw new MissingCRDException(resourceTypeName, null, e.getMessage(), e);
119+
}
120+
}
121+
}
122+
}
123+
124+
private static boolean matchesResourceType(String resourceTypeName,
125+
KubernetesClientException exception) {
126+
final var fullResourceName = exception.getFullResourceName();
127+
if (fullResourceName != null) {
128+
return resourceTypeName.equals(fullResourceName);
129+
} else {
130+
// extract matching information from URI in the message if available
131+
final var message = exception.getMessage();
132+
final var regex = Pattern
133+
.compile(".*http(s?)://[^/]*/api(s?)/(\\S*).*") // NOSONAR: input is controlled
134+
.matcher(message);
135+
if (regex.matches()) {
136+
var group = regex.group(3);
137+
if (group.endsWith(".")) {
138+
group = group.substring(0, group.length() - 1);
139+
}
140+
final var segments = Arrays.stream(group.split("/")).filter(Predicate.not(String::isEmpty))
141+
.collect(Collectors.toUnmodifiableList());
142+
if (segments.size() != 3) {
143+
return false;
144+
}
145+
final var targetResourceName = segments.get(2) + "." + segments.get(0);
146+
return resourceTypeName.equals(targetResourceName);
147+
}
148+
}
149+
return false;
150+
}
101151
}

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/Controller.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ public void start() throws OperatorException {
195195

196196
eventSourceManager.start();
197197
} catch (MissingCRDException e) {
198+
stop();
198199
throwMissingCRDException(crdName, specVersion, controllerName);
199200
}
200201
}

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ public void start() {
7171
try {
7272
eventSource.start();
7373
} catch (Exception e) {
74-
log.warn("Error starting {}", eventSource, e);
74+
log.warn("Error starting {} -> {}", eventSource, e);
75+
throw e;
7576
}
7677
}
7778
eventProcessor.start();
@@ -118,6 +119,7 @@ public final void registerEventSource(EventSource eventSource)
118119
}
119120
}
120121

122+
@SuppressWarnings("unchecked")
121123
public void broadcastOnResourceEvent(ResourceAction action, R resource, R oldResource) {
122124
for (var eventSource : eventSources) {
123125
if (eventSource instanceof ResourceEventAware) {
@@ -194,8 +196,9 @@ public Iterator<EventSource> iterator() {
194196
}
195197

196198
public Set<EventSource> all() {
197-
return new LinkedHashSet<>(sources.values().stream().flatMap(Collection::stream)
198-
.collect(Collectors.toList()));
199+
return sources.values().stream()
200+
.flatMap(Collection::stream)
201+
.collect(Collectors.toCollection(LinkedHashSet::new));
199202
}
200203

201204
public void clear() {
@@ -219,6 +222,7 @@ public void add(EventSource eventSource) {
219222
sources.computeIfAbsent(keyFor(eventSource), k -> new ArrayList<>()).add(eventSource);
220223
}
221224

225+
@SuppressWarnings("rawtypes")
222226
private Class getDependentType(EventSource source) {
223227
return source instanceof ResourceEventSource
224228
? ((ResourceEventSource) source).getResourceClass()
@@ -248,6 +252,7 @@ private String keyFor(Class<?> dependentType) {
248252
return key;
249253
}
250254

255+
@SuppressWarnings("unchecked")
251256
public <S> ResourceEventSource<R, S> get(Class<S> dependentType, String name) {
252257
final var sourcesForType = sources.get(keyFor(dependentType));
253258
if (sourcesForType == null || sourcesForType.isEmpty()) {

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerResourceEventSource.java

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,9 @@
1313

1414
import io.fabric8.kubernetes.api.model.HasMetadata;
1515
import io.fabric8.kubernetes.api.model.KubernetesResourceList;
16-
import io.fabric8.kubernetes.client.KubernetesClientException;
1716
import io.fabric8.kubernetes.client.dsl.FilterWatchListDeletable;
1817
import io.fabric8.kubernetes.client.informers.ResourceEventHandler;
1918
import io.fabric8.kubernetes.client.informers.SharedIndexInformer;
20-
import io.javaoperatorsdk.operator.MissingCRDException;
2119
import io.javaoperatorsdk.operator.api.config.ConfigurationService;
2220
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
2321
import io.javaoperatorsdk.operator.processing.Controller;
@@ -26,6 +24,7 @@
2624
import io.javaoperatorsdk.operator.processing.event.source.AbstractResourceEventSource;
2725
import io.javaoperatorsdk.operator.processing.event.source.ResourceCache;
2826

27+
import static io.javaoperatorsdk.operator.ReconcilerUtils.handleKubernetesClientException;
2928
import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getName;
3029
import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getUID;
3130
import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getVersion;
@@ -46,6 +45,7 @@ public class ControllerResourceEventSource<T extends HasMetadata>
4645
private final ControllerResourceCache<T> cache;
4746
private final TemporaryResourceCache<T> temporaryResourceCache;
4847

48+
@SuppressWarnings("unchecked")
4949
public ControllerResourceEventSource(Controller<T> controller) {
5050
super(controller.getConfiguration().getResourceClass());
5151
this.controller = controller;
@@ -87,9 +87,7 @@ public void start() {
8787
});
8888
}
8989
} catch (Exception e) {
90-
if (e instanceof KubernetesClientException) {
91-
handleKubernetesClientException(e);
92-
}
90+
handleKubernetesClientException(e, controller.getConfiguration().getResourceTypeName());
9391
throw e;
9492
}
9593
super.start();
@@ -193,17 +191,6 @@ public SharedIndexInformer<T> getInformer(String namespace) {
193191
return getInformers().get(Objects.requireNonNullElse(namespace, ANY_NAMESPACE_MAP_KEY));
194192
}
195193

196-
private void handleKubernetesClientException(Exception e) {
197-
KubernetesClientException ke = (KubernetesClientException) e;
198-
if (404 == ke.getCode()) {
199-
// only throw MissingCRDException if the 404 error occurs on the target CRD
200-
final var targetCRDName = controller.getConfiguration().getResourceTypeName();
201-
if (targetCRDName.equals(ke.getFullResourceName())) {
202-
throw new MissingCRDException(targetCRDName, null, e.getMessage(), e);
203-
}
204-
}
205-
}
206-
207194
@Override
208195
public Optional<T> getAssociated(T primary) {
209196
return get(ResourceID.fromResource(primary));

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import io.fabric8.kubernetes.client.informers.ResourceEventHandler;
1414
import io.fabric8.kubernetes.client.informers.SharedInformer;
1515
import io.fabric8.kubernetes.client.informers.cache.Store;
16+
import io.javaoperatorsdk.operator.ReconcilerUtils;
1617
import io.javaoperatorsdk.operator.processing.event.Event;
1718
import io.javaoperatorsdk.operator.processing.event.EventHandler;
1819
import io.javaoperatorsdk.operator.processing.event.ResourceID;
@@ -142,7 +143,13 @@ private void propagateEvent(T object) {
142143

143144
@Override
144145
public void start() {
145-
sharedInformer.run();
146+
try {
147+
sharedInformer.run();
148+
} catch (Exception e) {
149+
ReconcilerUtils.handleKubernetesClientException(e,
150+
HasMetadata.getFullResourceName(sharedInformer.getApiTypeClass()));
151+
throw e;
152+
}
146153
}
147154

148155
@Override

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/timer/TimerEventSource.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public class TimerEventSource<R extends HasMetadata>
2020
implements ResourceEventAware<R> {
2121
private static final Logger log = LoggerFactory.getLogger(TimerEventSource.class);
2222

23-
private final Timer timer = new Timer();
23+
private final Timer timer = new Timer(true);
2424
private final AtomicBoolean running = new AtomicBoolean();
2525
private final Map<ResourceID, EventProducerTimeTask> onceTasks = new ConcurrentHashMap<>();
2626

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package io.javaoperatorsdk.operator;
2+
3+
import io.fabric8.kubernetes.api.model.HasMetadata;
4+
import io.fabric8.kubernetes.api.model.KubernetesResourceList;
5+
import io.fabric8.kubernetes.client.KubernetesClient;
6+
import io.fabric8.kubernetes.client.V1ApiextensionAPIGroupDSL;
7+
import io.fabric8.kubernetes.client.dsl.ApiextensionsAPIGroupDSL;
8+
import io.fabric8.kubernetes.client.dsl.FilterWatchListDeletable;
9+
import io.fabric8.kubernetes.client.dsl.FilterWatchListMultiDeletable;
10+
import io.fabric8.kubernetes.client.dsl.MixedOperation;
11+
import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation;
12+
import io.fabric8.kubernetes.client.dsl.Resource;
13+
import io.fabric8.kubernetes.client.informers.SharedIndexInformer;
14+
15+
import static org.mockito.ArgumentMatchers.any;
16+
import static org.mockito.ArgumentMatchers.anyLong;
17+
import static org.mockito.ArgumentMatchers.anyString;
18+
import static org.mockito.ArgumentMatchers.nullable;
19+
import static org.mockito.Mockito.mock;
20+
import static org.mockito.Mockito.when;
21+
22+
@SuppressWarnings("unchecked")
23+
public class MockKubernetesClient {
24+
25+
public static <T extends HasMetadata> KubernetesClient client(Class<T> clazz) {
26+
final var client = mock(KubernetesClient.class);
27+
MixedOperation<T, KubernetesResourceList<T>, Resource<T>> resources =
28+
mock(MixedOperation.class);
29+
NonNamespaceOperation<T, KubernetesResourceList<T>, Resource<T>> nonNamespaceOperation =
30+
mock(NonNamespaceOperation.class);
31+
FilterWatchListMultiDeletable<T, KubernetesResourceList<T>> inAnyNamespace = mock(
32+
FilterWatchListMultiDeletable.class);
33+
FilterWatchListDeletable<T, KubernetesResourceList<T>> filterable =
34+
mock(FilterWatchListDeletable.class);
35+
when(resources.inNamespace(anyString())).thenReturn(nonNamespaceOperation);
36+
when(nonNamespaceOperation.withLabelSelector(nullable(String.class))).thenReturn(filterable);
37+
when(resources.inAnyNamespace()).thenReturn(inAnyNamespace);
38+
when(inAnyNamespace.withLabelSelector(nullable(String.class))).thenReturn(filterable);
39+
SharedIndexInformer<T> informer = mock(SharedIndexInformer.class);
40+
when(filterable.runnableInformer(anyLong())).thenReturn(informer);
41+
when(client.resources(clazz)).thenReturn(resources);
42+
43+
final var apiGroupDSL = mock(ApiextensionsAPIGroupDSL.class);
44+
when(client.apiextensions()).thenReturn(apiGroupDSL);
45+
final var v1 = mock(V1ApiextensionAPIGroupDSL.class);
46+
when(apiGroupDSL.v1()).thenReturn(v1);
47+
final var operation = mock(NonNamespaceOperation.class);
48+
when(v1.customResourceDefinitions()).thenReturn(operation);
49+
when(operation.withName(any())).thenReturn(mock(Resource.class));
50+
51+
return client;
52+
}
53+
}

operator-framework-core/src/test/java/io/javaoperatorsdk/operator/ReconcilerUtilsTest.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,25 @@
22

33
import org.junit.jupiter.api.Test;
44

5+
import io.fabric8.kubernetes.api.model.HasMetadata;
6+
import io.fabric8.kubernetes.api.model.Namespaced;
57
import io.fabric8.kubernetes.api.model.Pod;
8+
import io.fabric8.kubernetes.client.CustomResource;
9+
import io.fabric8.kubernetes.client.KubernetesClientException;
10+
import io.fabric8.kubernetes.model.annotation.Group;
11+
import io.fabric8.kubernetes.model.annotation.ShortNames;
12+
import io.fabric8.kubernetes.model.annotation.Version;
613
import io.javaoperatorsdk.operator.api.reconciler.Constants;
714
import io.javaoperatorsdk.operator.sample.simple.TestCustomReconciler;
815
import io.javaoperatorsdk.operator.sample.simple.TestCustomResource;
916

1017
import static io.javaoperatorsdk.operator.ReconcilerUtils.getDefaultFinalizerName;
1118
import static io.javaoperatorsdk.operator.ReconcilerUtils.getDefaultNameFor;
1219
import static io.javaoperatorsdk.operator.ReconcilerUtils.getDefaultReconcilerName;
20+
import static io.javaoperatorsdk.operator.ReconcilerUtils.handleKubernetesClientException;
1321
import static io.javaoperatorsdk.operator.ReconcilerUtils.isFinalizerValid;
1422
import static org.junit.jupiter.api.Assertions.assertEquals;
23+
import static org.junit.jupiter.api.Assertions.assertThrows;
1524
import static org.junit.jupiter.api.Assertions.assertTrue;
1625

1726
class ReconcilerUtilsTest {
@@ -39,4 +48,20 @@ void defaultFinalizerShouldWork() {
3948
void noFinalizerMarkerShouldWork() {
4049
assertTrue(isFinalizerValid(Constants.NO_FINALIZER));
4150
}
51+
52+
@Test
53+
void handleKubernetesExceptionShouldThrowMissingCRDExceptionWhenAppropriate() {
54+
assertThrows(MissingCRDException.class, () -> handleKubernetesClientException(
55+
new KubernetesClientException(
56+
"Failure executing: GET at: https://kubernetes.docker.internal:6443/apis/tomcatoperator.io/v1/tomcats. Message: Not Found.",
57+
404, null),
58+
HasMetadata.getFullResourceName(Tomcat.class)));
59+
}
60+
61+
@Group("tomcatoperator.io")
62+
@Version("v1")
63+
@ShortNames("tc")
64+
private static class Tomcat extends CustomResource<Void, Void> implements Namespaced {
65+
}
66+
4267
}

0 commit comments

Comments
 (0)