diff --git a/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/LivenessResource.java b/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/LivenessResource.java new file mode 100644 index 0000000000000..17c1cfada1230 --- /dev/null +++ b/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/LivenessResource.java @@ -0,0 +1,50 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * 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 org.talend.sdk.component.server.api; + +import static javax.ws.rs.core.MediaType.APPLICATION_JSON; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Response; + +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.talend.sdk.component.server.front.model.HealthStatus; + +@Path("liveness") +@Tag(name = "Health", description = "Kubernetes liveness probe endpoint.") +public interface LivenessResource { + + @GET + @Produces(APPLICATION_JSON) + @Operation(operationId = "getLiveness", + description = "Liveness probe: returns 200 when the JVM is healthy (no fatal error recorded). " + + "Returns 503 with a cause when a VirtualMachineError (e.g. OutOfMemoryError) has been intercepted.") + @APIResponse(responseCode = "200", + description = "Application is healthy.", + content = @Content(mediaType = APPLICATION_JSON, + schema = @Schema(implementation = HealthStatus.class))) + @APIResponse(responseCode = "503", + description = "Application has encountered a fatal error.", + content = @Content(mediaType = APPLICATION_JSON, + schema = @Schema(implementation = HealthStatus.class))) + Response getLiveness(); +} diff --git a/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/ReadinessResource.java b/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/ReadinessResource.java new file mode 100644 index 0000000000000..2b2a40d61eee0 --- /dev/null +++ b/component-server-parent/component-server-api/src/main/java/org/talend/sdk/component/server/api/ReadinessResource.java @@ -0,0 +1,50 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * 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 org.talend.sdk.component.server.api; + +import static javax.ws.rs.core.MediaType.APPLICATION_JSON; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Response; + +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.talend.sdk.component.server.front.model.HealthStatus; + +@Path("readiness") +@Tag(name = "Health", description = "Kubernetes readiness probe endpoint.") +public interface ReadinessResource { + + @GET + @Produces(APPLICATION_JSON) + @Operation(operationId = "getReadiness", + description = "Readiness probe: returns 200 when the component index is loaded and the server is ready " + + "to serve traffic. Returns 503 with a cause otherwise.") + @APIResponse(responseCode = "200", + description = "Server is ready.", + content = @Content(mediaType = APPLICATION_JSON, + schema = @Schema(implementation = HealthStatus.class))) + @APIResponse(responseCode = "503", + description = "Server is not ready.", + content = @Content(mediaType = APPLICATION_JSON, + schema = @Schema(implementation = HealthStatus.class))) + Response getReadiness(); +} diff --git a/component-server-parent/component-server-model/src/main/java/org/talend/sdk/component/server/front/model/HealthStatus.java b/component-server-parent/component-server-model/src/main/java/org/talend/sdk/component/server/front/model/HealthStatus.java new file mode 100644 index 0000000000000..ba6e5d4355599 --- /dev/null +++ b/component-server-parent/component-server-model/src/main/java/org/talend/sdk/component/server/front/model/HealthStatus.java @@ -0,0 +1,30 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * 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 org.talend.sdk.component.server.front.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class HealthStatus { + + private String status; + + private String cause; +} diff --git a/component-server-parent/component-server/pom.xml b/component-server-parent/component-server/pom.xml index db6f74dd31b5f..dcef08bcc9432 100644 --- a/component-server-parent/component-server/pom.xml +++ b/component-server-parent/component-server/pom.xml @@ -158,6 +158,12 @@ ${meecrowave.version} test + + org.mockito + mockito-core + ${mockito4.version} + test + org.apache.tomee ziplock diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/configuration/ComponentServerConfiguration.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/configuration/ComponentServerConfiguration.java index b0d36c0a521d7..7b7b4889b908e 100644 --- a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/configuration/ComponentServerConfiguration.java +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/configuration/ComponentServerConfiguration.java @@ -204,6 +204,12 @@ by its virtual gav (`groupId:artifactId:version`), @ConfigProperty(name = "talend.component.server.plugins.reloading.marker") private Optional pluginsReloadFileMarker; + @Inject + @Documentation("Whether the Vault connectivity check is included in the readiness probe. " + + "Set to true only when this server instance uses Vault for credential decryption.") + @ConfigProperty(name = "talend.component.server.health.vault.enabled", defaultValue = "false") + private Boolean healthVaultEnabled; + @PostConstruct private void init() { if (logRequests != null && logRequests) { diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/LivenessResourceImpl.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/LivenessResourceImpl.java new file mode 100644 index 0000000000000..4d7628e34adea --- /dev/null +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/LivenessResourceImpl.java @@ -0,0 +1,46 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * 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 org.talend.sdk.component.server.front; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import javax.ws.rs.core.Response; + +import org.talend.sdk.component.server.api.LivenessResource; +import org.talend.sdk.component.server.front.model.HealthStatus; +import org.talend.sdk.component.server.service.FatalState; + +@ApplicationScoped +public class LivenessResourceImpl implements LivenessResource { + + private static final String STATUS_UP = "UP"; + + private static final String STATUS_DOWN = "DOWN"; + + @Inject + private FatalState fatalState; + + @Override + public Response getLiveness() { + if (fatalState.hasFatalError()) { + return Response + .status(Response.Status.SERVICE_UNAVAILABLE) + .entity(new HealthStatus(STATUS_DOWN, fatalState.getCause())) + .build(); + } + return Response.ok(new HealthStatus(STATUS_UP, null)).build(); + } +} diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/ReadinessResourceImpl.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/ReadinessResourceImpl.java new file mode 100644 index 0000000000000..73e78a3076597 --- /dev/null +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/ReadinessResourceImpl.java @@ -0,0 +1,112 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * 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 org.talend.sdk.component.server.front; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import javax.ws.rs.core.Response; + +import org.talend.sdk.component.server.api.ReadinessResource; +import org.talend.sdk.component.server.configuration.ComponentServerConfiguration; +import org.talend.sdk.component.server.front.model.HealthStatus; +import org.talend.sdk.component.server.service.ComponentManagerService; +import org.talend.sdk.components.vault.client.VaultClient; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@ApplicationScoped +public class ReadinessResourceImpl implements ReadinessResource { + + private static final String STATUS_UP = "UP"; + + private static final String STATUS_DOWN = "DOWN"; + + private static final String VAULT_NOT_REACHABLE = "Vault is not reachable"; + + private static final long VAULT_CACHE_TTL_MS = 5_000L; + + @Inject + private ComponentManagerService componentManagerService; + + @Inject + private ComponentServerConfiguration configuration; + + @Inject + private VaultClient vaultClient; + + private final AtomicBoolean cachedVaultResult = new AtomicBoolean(true); + + private final AtomicLong lastVaultCheck = new AtomicLong(0L); + + private final Object vaultCheckLock = new Object(); + + @Override + public Response getReadiness() { + if (!componentManagerService.isStarted()) { + return Response + .status(Response.Status.SERVICE_UNAVAILABLE) + .entity(new HealthStatus(STATUS_DOWN, "Component index not ready")) + .build(); + } + if (configuration.getHealthVaultEnabled()) { + final HealthStatus vaultStatus = checkVaultCached(); + if (STATUS_DOWN.equals(vaultStatus.getStatus())) { + return Response.status(Response.Status.SERVICE_UNAVAILABLE).entity(vaultStatus).build(); + } + } + return Response.ok(new HealthStatus(STATUS_UP, null)).build(); + } + + private HealthStatus checkVaultCached() { + final long now = System.currentTimeMillis(); + if (now - lastVaultCheck.get() < VAULT_CACHE_TTL_MS) { + return cachedVaultResult.get() + ? new HealthStatus(STATUS_UP, null) + : new HealthStatus(STATUS_DOWN, VAULT_NOT_REACHABLE); + } + synchronized (vaultCheckLock) { + // re-check under the lock: another thread may have already refreshed the cache + // while this thread was waiting to enter the monitor + final long recheckNow = System.currentTimeMillis(); + if (recheckNow - lastVaultCheck.get() < VAULT_CACHE_TTL_MS) { + return cachedVaultResult.get() + ? new HealthStatus(STATUS_UP, null) + : new HealthStatus(STATUS_DOWN, VAULT_NOT_REACHABLE); + } + try { + final boolean reachable = vaultClient.ping(); + cachedVaultResult.set(reachable); + lastVaultCheck.set(recheckNow); + if (!reachable) { + log.warn("Readiness check: {}", VAULT_NOT_REACHABLE); + return new HealthStatus(STATUS_DOWN, VAULT_NOT_REACHABLE); + } + } catch (final VirtualMachineError vme) { + throw vme; + } catch (final Throwable t) { + cachedVaultResult.set(false); + lastVaultCheck.set(recheckNow); + log.warn("Readiness check failed: Vault connectivity check failed", t); + return new HealthStatus(STATUS_DOWN, "Vault connectivity check failed"); + } + } + return new HealthStatus(STATUS_UP, null); + } +} diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/error/DefaultExceptionHandler.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/error/DefaultExceptionHandler.java index e3efdaf8db3a2..d6f16701e4e87 100644 --- a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/error/DefaultExceptionHandler.java +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/error/DefaultExceptionHandler.java @@ -22,7 +22,9 @@ import javax.annotation.PostConstruct; import javax.enterprise.context.Dependent; import javax.inject.Inject; +import javax.servlet.http.HttpServletRequest; import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Context; import javax.ws.rs.core.Response; import javax.ws.rs.ext.ExceptionMapper; import javax.ws.rs.ext.Provider; @@ -30,6 +32,7 @@ import org.talend.sdk.component.server.configuration.ComponentServerConfiguration; import org.talend.sdk.component.server.front.model.ErrorDictionary; import org.talend.sdk.component.server.front.model.error.ErrorPayload; +import org.talend.sdk.component.server.service.FatalState; import lombok.extern.slf4j.Slf4j; @@ -41,6 +44,12 @@ public class DefaultExceptionHandler implements ExceptionMapper { @Inject private ComponentServerConfiguration configuration; + @Inject + private FatalState fatalState; + + @Context + private HttpServletRequest request; + private boolean replaceException; @PostConstruct @@ -50,6 +59,12 @@ private void init() { @Override public Response toResponse(final Throwable exception) { + if (exception instanceof VirtualMachineError) { + final String requestUri = request != null ? request.getRequestURI() : null; + final String requestPath = requestUri != null ? requestUri : "(unknown)"; + final String cause = exception.getClass().getSimpleName() + " during request " + requestPath; + fatalState.markFatal(cause); + } log.error("[DefaultExceptionHandler#toResponse] Throwable: ", exception); final Response response; if (exception instanceof WebApplicationException applicationException) { diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/ComponentManagerService.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/ComponentManagerService.java index 3f122a1285e45..f0f976c4feb6d 100644 --- a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/ComponentManagerService.java +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/ComponentManagerService.java @@ -121,7 +121,7 @@ public class ComponentManagerService { private Connectors connectors; - private boolean started; + private volatile boolean started; private Path m2; @@ -460,4 +460,8 @@ public ComponentManager manager() { return instance; } + public boolean isStarted() { + return started; + } + } diff --git a/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/FatalState.java b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/FatalState.java new file mode 100644 index 0000000000000..ab95a4ec323e9 --- /dev/null +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/service/FatalState.java @@ -0,0 +1,65 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * 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 org.talend.sdk.component.server.service; + +import java.util.concurrent.atomic.AtomicReference; + +import javax.enterprise.context.ApplicationScoped; + +import lombok.extern.slf4j.Slf4j; + +/** + * Singleton that records whether the JVM has encountered a fatal error (VirtualMachineError) + * during request processing. Once set, the state is never cleared — the pod must be restarted. + */ +@Slf4j +@ApplicationScoped +public class FatalState { + + private final AtomicReference fatalCause = new AtomicReference<>(null); + + /** + * Records a fatal error. Subsequent calls are no-ops; the first cause wins. + * + * @param cause human-readable description of the error + */ + public void markFatal(final String cause) { + if (fatalCause.compareAndSet(null, cause)) { + log.error("Fatal JVM error recorded — liveness probe will now return DOWN: {}", cause); + } + } + + /** + * @return {@code true} if a fatal error has been recorded + */ + public boolean hasFatalError() { + return fatalCause.get() != null; + } + + /** + * @return the cause of the fatal error, or {@code null} if none has been recorded + */ + public String getCause() { + return fatalCause.get(); + } + + /** + * Resets the fatal state. Intended for use in tests only — never call in production code. + */ + public void reset() { + fatalCause.set(null); + } +} diff --git a/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/LivenessResourceImplIT.java b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/LivenessResourceImplIT.java new file mode 100644 index 0000000000000..6ae2971c371ef --- /dev/null +++ b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/LivenessResourceImplIT.java @@ -0,0 +1,91 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * 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 org.talend.sdk.component.server.front; + +import static javax.ws.rs.core.MediaType.APPLICATION_JSON_TYPE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import javax.inject.Inject; +import javax.ws.rs.client.WebTarget; +import javax.ws.rs.core.Response; + +import org.apache.meecrowave.junit5.MonoMeecrowaveConfig; +import org.junit.jupiter.api.Test; +import org.talend.sdk.component.server.front.model.HealthStatus; +import org.talend.sdk.component.server.service.FatalState; + +@MonoMeecrowaveConfig +class LivenessResourceImplIT { + + @Inject + private WebTarget base; + + @Inject + private FatalState fatalState; + + @Test + void livenessReturns200WhenNoFatalError() { + try (Response response = base.path("liveness").request(APPLICATION_JSON_TYPE).get()) { + assertEquals(200, response.getStatus()); + final HealthStatus status = response.readEntity(HealthStatus.class); + assertNotNull(status); + assertEquals("UP", status.getStatus()); + assertNull(status.getCause()); + } + } + + @Test + void readinessReturns200WhenReady() { + try (Response response = base.path("readiness").request(APPLICATION_JSON_TYPE).get()) { + assertEquals(200, response.getStatus()); + final HealthStatus status = response.readEntity(HealthStatus.class); + assertNotNull(status); + assertEquals("UP", status.getStatus()); + } + } + + @Test + void livenessAndReadinessAreIndependentFromEnvironment() { + try (Response envResponse = base.path("environment").request(APPLICATION_JSON_TYPE).get()) { + assertEquals(200, envResponse.getStatus()); + } + + try (Response livenessResponse = base.path("liveness").request(APPLICATION_JSON_TYPE).get()) { + assertEquals(200, livenessResponse.getStatus()); + } + + try (Response readinessResponse = base.path("readiness").request(APPLICATION_JSON_TYPE).get()) { + assertEquals(200, readinessResponse.getStatus()); + } + } + + @Test + void livenessReturns503WhenFatalErrorRecorded() { + fatalState.markFatal("simulated OOM during test"); + try (Response response = base.path("liveness").request(APPLICATION_JSON_TYPE).get()) { + assertEquals(503, response.getStatus()); + final HealthStatus status = response.readEntity(HealthStatus.class); + assertNotNull(status); + assertEquals("DOWN", status.getStatus()); + assertNotNull(status.getCause()); + } finally { + // reset the singleton state so other tests are not affected + fatalState.reset(); + } + } +} diff --git a/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/ReadinessResourceImplTest.java b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/ReadinessResourceImplTest.java new file mode 100644 index 0000000000000..acbe84100b853 --- /dev/null +++ b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/ReadinessResourceImplTest.java @@ -0,0 +1,157 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * 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 org.talend.sdk.component.server.front; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import javax.ws.rs.core.Response; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.talend.sdk.component.server.configuration.ComponentServerConfiguration; +import org.talend.sdk.component.server.front.model.HealthStatus; +import org.talend.sdk.component.server.service.ComponentManagerService; +import org.talend.sdk.components.vault.client.VaultClient; + +class ReadinessResourceImplTest { + + private AutoCloseable closeable; + + @Mock + private ComponentManagerService componentManagerService; + + @Mock + private ComponentServerConfiguration configuration; + + @Mock + private VaultClient vaultClient; + + @InjectMocks + private ReadinessResourceImpl readinessResource; + + @BeforeEach + void setUp() { + closeable = MockitoAnnotations.openMocks(this); + } + + @AfterEach + void tearDown() throws Exception { + closeable.close(); + } + + @Test + void returns503WhenIndexNotReady() { + when(componentManagerService.isStarted()).thenReturn(false); + + final Response response = readinessResource.getReadiness(); + + assertEquals(503, response.getStatus()); + final HealthStatus status = (HealthStatus) response.getEntity(); + assertEquals("DOWN", status.getStatus()); + assertEquals("Component index not ready", status.getCause()); + } + + @Test + void returns200WhenIndexReadyAndVaultReachable() { + when(componentManagerService.isStarted()).thenReturn(true); + when(configuration.getHealthVaultEnabled()).thenReturn(false); + + final Response response = readinessResource.getReadiness(); + + assertEquals(200, response.getStatus()); + final HealthStatus status = (HealthStatus) response.getEntity(); + assertEquals("UP", status.getStatus()); + } + + @Test + void returns503WhenVaultNotReachable() { + when(componentManagerService.isStarted()).thenReturn(true); + when(configuration.getHealthVaultEnabled()).thenReturn(true); + when(vaultClient.ping()).thenReturn(false); + + final Response response = readinessResource.getReadiness(); + + assertEquals(503, response.getStatus()); + final HealthStatus status = (HealthStatus) response.getEntity(); + assertEquals("DOWN", status.getStatus()); + assertEquals("Vault is not reachable", status.getCause()); + } + + @Test + void returns503WhenVaultThrows() { + when(componentManagerService.isStarted()).thenReturn(true); + when(configuration.getHealthVaultEnabled()).thenReturn(true); + when(vaultClient.ping()) + .thenThrow(new RuntimeException("connection refused: ssl handshake to internal-host failed")); + + final Response response = readinessResource.getReadiness(); + + assertEquals(503, response.getStatus()); + final HealthStatus status = (HealthStatus) response.getEntity(); + assertEquals("DOWN", status.getStatus()); + assertEquals("Vault connectivity check failed", status.getCause()); + } + + @Test + void concurrentReadinessChecksTriggerVaultPingOnlyOnceAtTtlExpiry() throws Exception { + when(componentManagerService.isStarted()).thenReturn(true); + when(configuration.getHealthVaultEnabled()).thenReturn(true); + when(vaultClient.ping()).thenAnswer(invocation -> { + // widen the race window so concurrent callers are likely to observe the expired cache + Thread.sleep(50); + return true; + }); + + final int threadCount = 20; + final ExecutorService executor = Executors.newFixedThreadPool(threadCount); + final CountDownLatch ready = new CountDownLatch(threadCount); + final CountDownLatch start = new CountDownLatch(1); + final List> futures = new ArrayList<>(); + try { + for (int i = 0; i < threadCount; i++) { + futures.add(executor.submit(() -> { + ready.countDown(); + start.await(); + return readinessResource.getReadiness(); + })); + } + ready.await(); + start.countDown(); + for (final Future future : futures) { + assertEquals(200, future.get().getStatus()); + } + } finally { + executor.shutdown(); + } + + // exactly one thread should have performed the actual Vault ping for this TTL window + verify(vaultClient, times(1)).ping(); + } +} diff --git a/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/error/DefaultExceptionHandlerTest.java b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/error/DefaultExceptionHandlerTest.java new file mode 100644 index 0000000000000..60a91549c90ce --- /dev/null +++ b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/error/DefaultExceptionHandlerTest.java @@ -0,0 +1,90 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * 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 org.talend.sdk.component.server.front.error; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import javax.servlet.http.HttpServletRequest; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.talend.sdk.component.server.configuration.ComponentServerConfiguration; +import org.talend.sdk.component.server.service.FatalState; + +class DefaultExceptionHandlerTest { + + private AutoCloseable closeable; + + @Mock + private ComponentServerConfiguration configuration; + + @Mock + private FatalState fatalState; + + @Mock + private HttpServletRequest request; + + @InjectMocks + private DefaultExceptionHandler handler; + + @BeforeEach + void setUp() { + closeable = MockitoAnnotations.openMocks(this); + } + + @AfterEach + void tearDown() throws Exception { + closeable.close(); + } + + @Test + void virtualMachineErrorCauseOmitsExceptionMessage() { + when(request.getRequestURI()).thenReturn("/api/v1/component/index"); + final OutOfMemoryError error = new OutOfMemoryError("some very large heap dump detail that must not leak"); + + handler.toResponse(error); + + final ArgumentCaptor causeCaptor = ArgumentCaptor.forClass(String.class); + verify(fatalState).markFatal(causeCaptor.capture()); + final String cause = causeCaptor.getValue(); + assertTrue(cause.contains("OutOfMemoryError")); + assertTrue(cause.contains("/api/v1/component/index")); + assertFalse(cause.contains("some very large heap dump detail")); + } + + @Test + void virtualMachineErrorCauseHandlesUnknownRequest() { + when(request.getRequestURI()).thenReturn(null); + final StackOverflowError error = new StackOverflowError("deep recursion detail that must not leak"); + + handler.toResponse(error); + + final ArgumentCaptor causeCaptor = ArgumentCaptor.forClass(String.class); + verify(fatalState).markFatal(causeCaptor.capture()); + final String cause = causeCaptor.getValue(); + assertTrue(cause.contains("StackOverflowError")); + assertTrue(cause.contains("(unknown)")); + assertFalse(cause.contains("deep recursion detail")); + } +} diff --git a/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/FatalStateTest.java b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/FatalStateTest.java new file mode 100644 index 0000000000000..e2c7da912dffe --- /dev/null +++ b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/service/FatalStateTest.java @@ -0,0 +1,56 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * 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 org.talend.sdk.component.server.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class FatalStateTest { + + private FatalState fatalState; + + @BeforeEach + void setUp() { + fatalState = new FatalState(); + } + + @Test + void initiallyNoFatalError() { + assertFalse(fatalState.hasFatalError()); + assertNull(fatalState.getCause()); + } + + @Test + void markFatalSetsCause() { + fatalState.markFatal("OutOfMemoryError during /api/v1/component/index"); + + assertTrue(fatalState.hasFatalError()); + assertEquals("OutOfMemoryError during /api/v1/component/index", fatalState.getCause()); + } + + @Test + void markFatalIsIdempotentFirstCauseWins() { + fatalState.markFatal("first error"); + fatalState.markFatal("second error"); + + assertEquals("first error", fatalState.getCause()); + } +} diff --git a/vault-client/src/main/java/org/talend/sdk/components/vault/client/VaultClient.java b/vault-client/src/main/java/org/talend/sdk/components/vault/client/VaultClient.java index 74cf8485a02c8..aa5979bc46c42 100644 --- a/vault-client/src/main/java/org/talend/sdk/components/vault/client/VaultClient.java +++ b/vault-client/src/main/java/org/talend/sdk/components/vault/client/VaultClient.java @@ -206,6 +206,33 @@ public Thread newThread(final Runnable r) { }); } + /** + * Checks connectivity to Vault. + * + * @return {@code true} if Vault is not configured ({@code no-vault}) or responds with HTTP 200 + * (initialized, unsealed, active), {@code false} otherwise (non-200 status or a transport-level error). + */ + public boolean ping() { + if ("no-vault".equals(setup.getVaultUrl())) { + return true; + } + Response response = null; + try { + response = vault + .path("v1/sys/health") + .request() + .get(); + return response.getStatus() == Response.Status.OK.getStatusCode(); + } catch (final javax.ws.rs.ProcessingException e) { + log.warn("Vault ping failed: {}", e.getMessage()); + return false; + } finally { + if (response != null) { + response.close(); + } + } + } + @SneakyThrows public Map decrypt(final Map values) { return decrypt(values, null); diff --git a/vault-client/src/test/java/org/talend/sdk/components/vault/client/VaultClientTest.java b/vault-client/src/test/java/org/talend/sdk/components/vault/client/VaultClientTest.java index ff2c0b19db416..6747fcb8ff858 100644 --- a/vault-client/src/test/java/org/talend/sdk/components/vault/client/VaultClientTest.java +++ b/vault-client/src/test/java/org/talend/sdk/components/vault/client/VaultClientTest.java @@ -17,6 +17,7 @@ import static javax.ws.rs.core.MediaType.APPLICATION_JSON_TYPE; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -42,6 +43,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.talend.sdk.components.vault.client.vault.VaultHealthMock; import org.talend.sdk.components.vault.server.error.ErrorPayload; @MonoMeecrowaveConfig @@ -75,6 +77,7 @@ void setup() { vault.setSecret(() -> "Test-Secret"); vault.getAuthToken().set(null); vault.getCache().clear(); + VaultHealthMock.reset(); } public static final HashMap DEMO_MAP = new HashMap() { @@ -126,6 +129,41 @@ void decryptWithoutTenant() { assertEquals("test", result.get("configuration.password")); } + @Test + void pingReturnsTrueForNoVault() { + final String originalUrl = setup.getVaultUrl(); + setup.setVaultUrl("no-vault"); + try { + assertTrue(vault.ping()); + } finally { + setup.setVaultUrl(originalUrl); + } + } + + @Test + void pingReturnsTrueWhenVaultRespondsWithHttp200() { + VaultHealthMock.setStatus(200); + assertTrue(vault.ping()); + } + + @Test + void pingReturnsFalseWhenVaultRespondsWithNonHttp200() { + // e.g. 503 = Vault is sealed/not initialized + VaultHealthMock.setStatus(503); + assertFalse(vault.ping()); + } + + @Test + void pingReturnsFalseOnProcessingException() { + final WebTarget original = vault.getVault(); + vault.setVault(client.target("http://localhost:1")); + try { + assertFalse(vault.ping()); + } finally { + vault.setVault(original); + } + } + @Test void executeWithEncrypted() { final Response response = vaultBase() diff --git a/vault-client/src/test/java/org/talend/sdk/components/vault/client/vault/VaultHealthMock.java b/vault-client/src/test/java/org/talend/sdk/components/vault/client/vault/VaultHealthMock.java new file mode 100644 index 0000000000000..02d6e3eea5f15 --- /dev/null +++ b/vault-client/src/test/java/org/talend/sdk/components/vault/client/vault/VaultHealthMock.java @@ -0,0 +1,48 @@ +/** + * Copyright (C) 2006-2026 Talend Inc. - www.talend.com + * + * 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 org.talend.sdk.components.vault.client.vault; + +import javax.enterprise.context.ApplicationScoped; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +/** + * Mocks Vault's {@code v1/sys/health} endpoint so {@link org.talend.sdk.components.vault.client.VaultClient#ping()} + * can be exercised against a controllable HTTP status code in tests. + */ +@Path("v1/sys/health") +@ApplicationScoped +public class VaultHealthMock { + + private static volatile int status = 200; + + public static void setStatus(final int newStatus) { + status = newStatus; + } + + public static void reset() { + status = 200; + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public Response health() { + return Response.status(status).entity("{}").build(); + } +}