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 a9d7be504bed5..d501ce6c1ff32 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 847360ece24c9..93d740a794d7b 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 @@ -201,6 +201,12 @@ public class ComponentServerConfiguration { @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.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..d833bfc76c238 --- /dev/null +++ b/component-server-parent/component-server/src/main/java/org/talend/sdk/component/server/front/ReadinessResourceImpl.java @@ -0,0 +1,99 @@ +/** + * 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 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); + + @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 is not reachable"); + } + try { + final boolean reachable = vaultClient.ping(); + cachedVaultResult.set(reachable); + lastVaultCheck.set(now); + if (!reachable) { + log.warn("Readiness check: Vault is not reachable"); + return new HealthStatus(STATUS_DOWN, "Vault is not reachable"); + } + } catch (final VirtualMachineError vme) { + throw vme; + } catch (final Throwable t) { + cachedVaultResult.set(false); + lastVaultCheck.set(now); + final String cause = "Vault connectivity check failed: " + t.getMessage(); + log.warn("Readiness check failed: {}", cause); + return new HealthStatus(STATUS_DOWN, cause); + } + 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 f6cbe9244fe66..6b4688d78ac2f 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 requestPath = request != null ? request.getRequestURI() : "(unknown)"; + final String cause = exception.getClass().getSimpleName() + " during request " + + requestPath + ": " + exception.getMessage(); + fatalState.markFatal(cause); + } log.error("[DefaultExceptionHandler#toResponse] Throwable: ", exception); final Response response; if (exception instanceof WebApplicationException) { 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 edd9d0f533459..799ca4e8fed11 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..8c149043ff180 --- /dev/null +++ b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/LivenessResourceImplIT.java @@ -0,0 +1,87 @@ +/** + * 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() { + final 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() { + final 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() { + final Response envResponse = base.path("environment").request(APPLICATION_JSON_TYPE).get(); + assertEquals(200, envResponse.getStatus()); + + final Response livenessResponse = base.path("liveness").request(APPLICATION_JSON_TYPE).get(); + assertEquals(200, livenessResponse.getStatus()); + + final Response readinessResponse = base.path("readiness").request(APPLICATION_JSON_TYPE).get(); + assertEquals(200, readinessResponse.getStatus()); + } + + @Test + void livenessReturns503WhenFatalErrorRecorded() { + fatalState.markFatal("simulated OOM during test"); + try { + final 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..687e3270fb43f --- /dev/null +++ b/component-server-parent/component-server/src/test/java/org/talend/sdk/component/server/front/ReadinessResourceImplTest.java @@ -0,0 +1,110 @@ +/** + * 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.when; + +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")); + + final Response response = readinessResource.getReadiness(); + + assertEquals(503, response.getStatus()); + final HealthStatus status = (HealthStatus) response.getEntity(); + assertEquals("DOWN", status.getStatus()); + } +} 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 c6d805ca35225..bdf1fe60771b0 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 @@ -201,6 +201,33 @@ public Thread newThread(final Runnable r) { }); } + /** + * Checks connectivity to Vault. + * + * @return {@code true} if Vault is reachable or if Vault is not configured ({@code no-vault}), + * {@code false} if a transport-level error prevents reaching Vault. + */ + public boolean ping() { + if ("no-vault".equals(setup.getVaultUrl())) { + return true; + } + Response response = null; + try { + response = vault + .path("v1/sys/health") + .request() + .get(); + return true; + } 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);