diff --git a/docs/modules/servers/partials/operate/webadmin.adoc b/docs/modules/servers/partials/operate/webadmin.adoc index ddbc85df079..8682405a308 100644 --- a/docs/modules/servers/partials/operate/webadmin.adoc +++ b/docs/modules/servers/partials/operate/webadmin.adoc @@ -94,6 +94,53 @@ Response codes: services can still be used. * 503: At least one check have answered with a Unhealthy status +=== Check specific components + +Performs health checks for the given components. Components are +referenced by their URL encoded names. + +.... +curl -XGET http://ip:port/healthcheck?check=HealthCheck1&check=HealthCheck%20two +.... + +Will return a list of healthChecks execution result, with an aggregated +result: + +.... +{ + "status": "healthy", + "checks": [ + { + "componentName": "HealthCheck1", + "escapedComponentName": "HealthCheck1", + "status": "healthy" + "cause": null + }, + { + "componentName": "HealthCheck two", + "escapedComponentName": "HealthCheck%20two", + "status": "healthy" + "cause": null + } + ] +} +.... + +*status* field can be: + +* *healthy*: Component works normally +* *degraded*: Component works in degraded mode. Some non-critical +services may not be working, or latencies are high, for example. Cause +contains explanations. +* *unhealthy*: The component is currently not working. Cause contains +explanations. + +Response codes: + +* 200: All checks have answered with a Healthy or Degraded status. James +services can still be used. +* 503: At least one check have answered with a Unhealthy status + === Check single component Performs a health check for the given component. The component is diff --git a/server/protocols/webadmin/webadmin-core/src/main/java/org/apache/james/webadmin/routes/HealthCheckRoutes.java b/server/protocols/webadmin/webadmin-core/src/main/java/org/apache/james/webadmin/routes/HealthCheckRoutes.java index 2890a1d7be6..28fcfe0cf78 100644 --- a/server/protocols/webadmin/webadmin-core/src/main/java/org/apache/james/webadmin/routes/HealthCheckRoutes.java +++ b/server/protocols/webadmin/webadmin-core/src/main/java/org/apache/james/webadmin/routes/HealthCheckRoutes.java @@ -21,13 +21,18 @@ import static org.apache.james.util.ReactorUtils.DEFAULT_CONCURRENCY; +import java.util.Collection; import java.util.List; +import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; import jakarta.inject.Inject; import jakarta.inject.Named; import org.apache.commons.lang3.NotImplementedException; +import org.apache.james.core.healthcheck.ComponentName; import org.apache.james.core.healthcheck.HealthCheck; import org.apache.james.core.healthcheck.Result; import org.apache.james.core.healthcheck.ResultStatus; @@ -42,6 +47,7 @@ import org.slf4j.LoggerFactory; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -56,8 +62,9 @@ public class HealthCheckRoutes implements PublicRoutes { public static final String HEALTHCHECK = "/healthcheck"; public static final String CHECKS = "/checks"; - + private static final String PARAM_COMPONENT_NAME = "componentName"; + private static final String QUERY_PARAM_COMPONENT_NAMES = "check"; private final JsonTransformer jsonTransformer; private final Set healthChecks; @@ -81,7 +88,9 @@ public void define(Service service) { } public Object validateHealthChecks(Request request, Response response) { - List results = executeHealthChecks().collectList().block(); + Set selectedComponentNames = getComponentNames(request); + Collection selectedHealthChecks = selectHealthChecks(selectedComponentNames); + List results = executeHealthChecks(selectedHealthChecks).collectList().block(); ResultStatus status = retrieveAggregationStatus(results); response.status(getCorrespondingStatusCode(status)); return new HeathCheckAggregationExecutionResultDto(status, mapResultToDto(results)); @@ -102,10 +111,40 @@ public Object performHealthCheckForComponent(Request request, Response response) public Object getHealthChecks(Request request, Response response) { return healthChecks.stream() - .map(healthCheck -> new HealthCheckDto(healthCheck.componentName())) - .collect(ImmutableList.toImmutableList()); + .map(healthCheck -> new HealthCheckDto(healthCheck.componentName())) + .collect(ImmutableList.toImmutableList()); + } + + private Collection selectHealthChecks(Set selectedComponentNames) { + if (selectedComponentNames.isEmpty()) { + return healthChecks; + } else { + return getHealthChecks(selectedComponentNames); + } + } + + private Set getComponentNames(Request request) { + return Optional.ofNullable(request.queryParamsValues(QUERY_PARAM_COMPONENT_NAMES)) + .stream() + .flatMap(Stream::of) + .map(ComponentName::new) + .collect(ImmutableSet.toImmutableSet()); + } + + private Collection getHealthChecks(Set selectedComponentNames) { + Set componentNames = healthChecks.stream().map(HealthCheck::componentName).collect(ImmutableSet.toImmutableSet()); + List nonExistedComponentNames = selectedComponentNames.stream() + .filter(selectedComponentName -> !componentNames.contains(selectedComponentName)) + .toList(); + if (!nonExistedComponentNames.isEmpty()) { + throw throw404(nonExistedComponentNames.stream().map(ComponentName::getName).toList()); + } + + return healthChecks.stream() + .filter(healthCheck -> selectedComponentNames.contains(healthCheck.componentName())) + .toList(); } - + private int getCorrespondingStatusCode(ResultStatus resultStatus) { switch (resultStatus) { case HEALTHY: @@ -151,6 +190,10 @@ private void logFailedCheck(Result result) { } private Flux executeHealthChecks() { + return executeHealthChecks(healthChecks); + } + + private Flux executeHealthChecks(Collection healthChecks) { return Flux.fromIterable(healthChecks) .flatMap(HealthCheck::check, DEFAULT_CONCURRENCY) .doOnNext(this::logFailedCheck); @@ -168,10 +211,19 @@ private ImmutableList mapResultToDto(List .map(HealthCheckExecutionResultDto::new) .collect(ImmutableList.toImmutableList()); } - + private HaltException throw404(String componentName) { + return throw404("Component with name %s cannot be found", componentName); + } + + private HaltException throw404(Collection componentNames) { + return throw404("Components with name %s cannot be found", + componentNames.stream().collect(Collectors.joining(", "))); + } + + private HaltException throw404(String message, String componentNames) { return ErrorResponder.builder() - .message("Component with name %s cannot be found", componentName) + .message(message, componentNames) .statusCode(HttpStatus.NOT_FOUND_404) .type(ErrorResponder.ErrorType.NOT_FOUND) .haltError(); diff --git a/server/protocols/webadmin/webadmin-core/src/test/java/org/apache/james/webadmin/routes/HealthCheckRoutesTest.java b/server/protocols/webadmin/webadmin-core/src/test/java/org/apache/james/webadmin/routes/HealthCheckRoutesTest.java index a1e25474104..e54850f98dc 100644 --- a/server/protocols/webadmin/webadmin-core/src/test/java/org/apache/james/webadmin/routes/HealthCheckRoutesTest.java +++ b/server/protocols/webadmin/webadmin-core/src/test/java/org/apache/james/webadmin/routes/HealthCheckRoutesTest.java @@ -112,6 +112,7 @@ void validateHealthChecksShouldReturnOkWhenNoHealthChecks() { void validateHealthChecksShouldReturnOkWhenHealthChecksAreHealthy() { healthChecks.add(healthCheck(Result.healthy(COMPONENT_NAME_1))); healthChecks.add(healthCheck(Result.healthy(COMPONENT_NAME_2))); + String healthCheckBody = "{\"status\": \"healthy\"," + " \"checks\": [" + @@ -144,6 +145,7 @@ void validateHealthChecksShouldReturnOkWhenHealthChecksAreHealthy() { void validateHealthChecksShouldReturnInternalErrorWhenOneHealthCheckIsUnhealthy() { healthChecks.add(healthCheck(Result.unhealthy(COMPONENT_NAME_1, "cause"))); healthChecks.add(healthCheck(Result.healthy(COMPONENT_NAME_2))); + String healthCheckBody = "{\"status\": \"unhealthy\"," + " \"checks\": [" + @@ -176,6 +178,7 @@ void validateHealthChecksShouldReturnInternalErrorWhenOneHealthCheckIsUnhealthy( void validateHealthChecksShouldReturnInternalErrorWhenAllHealthChecksAreUnhealthy() { healthChecks.add(healthCheck(Result.unhealthy(COMPONENT_NAME_1, "cause"))); healthChecks.add(healthCheck(Result.unhealthy(COMPONENT_NAME_2, SAMPLE_CAUSE))); + String healthCheckBody = "{\"status\": \"unhealthy\"," + " \"checks\": [" + @@ -208,6 +211,7 @@ void validateHealthChecksShouldReturnInternalErrorWhenAllHealthChecksAreUnhealth void validateHealthChecksShouldReturnInternalErrorWhenOneHealthCheckIsDegraded() { healthChecks.add(healthCheck(Result.degraded(COMPONENT_NAME_1, "cause"))); healthChecks.add(healthCheck(Result.healthy(COMPONENT_NAME_2))); + String healthCheckBody = "{\"status\": \"degraded\"," + " \"checks\": [" + @@ -240,6 +244,7 @@ void validateHealthChecksShouldReturnInternalErrorWhenOneHealthCheckIsDegraded() void validateHealthChecksShouldReturnInternalErrorWhenAllHealthCheckAreDegraded() { healthChecks.add(healthCheck(Result.degraded(COMPONENT_NAME_1, "cause"))); healthChecks.add(healthCheck(Result.degraded(COMPONENT_NAME_2, "cause"))); + String healthCheckBody = "{\"status\": \"degraded\"," + " \"checks\": [" + @@ -298,11 +303,144 @@ void validateHealthChecksShouldReturnStatusUnHealthyWhenOneIsUnHealthyAndOtherIs .when(Option.IGNORING_ARRAY_ORDER) .isEqualTo(healthCheckBody); } + + @Test + void validateHealthChecksShouldReturnOkWhenSelectedHealthCheckIsHealthy() { + healthChecks.add(healthCheck(Result.healthy(COMPONENT_NAME_1))); + healthChecks.add(healthCheck(Result.unhealthy(COMPONENT_NAME_2, "cause"))); + + String healthCheckBody = + "{\"status\": \"healthy\"," + + " \"checks\": [" + + " {" + + " \"componentName\": \"component-1\"," + + " \"escapedComponentName\": \"component-1\"," + + " \"status\": \"healthy\"," + + " \"cause\": null" + + "}]}"; + + String retrieveBody = + given() + .queryParam("check", "component-1") + .when() + .get() + .then() + .statusCode(HttpStatus.OK_200) + .extract() + .body().asString(); + + assertThatJson(retrieveBody) + .when(Option.IGNORING_ARRAY_ORDER) + .isEqualTo(healthCheckBody); + } + + @Test + void validateHealthChecksShouldReturnOkWhenSelectedHealthChecksAreHealthy() { + healthChecks.add(healthCheck(Result.healthy(COMPONENT_NAME_1))); + healthChecks.add(healthCheck(Result.healthy(COMPONENT_NAME_2))); + healthChecks.add(healthCheck(Result.unhealthy(COMPONENT_NAME_3, "cause"))); + + String healthCheckBody = + "{\"status\": \"healthy\"," + + " \"checks\": [" + + " {" + + " \"componentName\": \"component-1\"," + + " \"escapedComponentName\": \"component-1\"," + + " \"status\": \"healthy\"," + + " \"cause\": null" + + " }," + + " {" + + " \"componentName\": \"component-2\"," + + " \"escapedComponentName\": \"component-2\"," + + " \"status\": \"healthy\"," + + " \"cause\": null" + + "}]}"; + + String retrieveBody = + given() + .queryParam("check", "component-1", "component-2") + .when() + .get() + .then() + .statusCode(HttpStatus.OK_200) + .extract() + .body().asString(); + + assertThatJson(retrieveBody) + .when(Option.IGNORING_ARRAY_ORDER) + .isEqualTo(healthCheckBody); + } + + @Test + void validateHealthChecksShouldReturnStatusUnHealthyWhenOneOfSelectedHealthChecksIsUnhealthy() { + healthChecks.add(healthCheck(Result.healthy(COMPONENT_NAME_1))); + healthChecks.add(healthCheck(Result.unhealthy(COMPONENT_NAME_2, "cause"))); + + String healthCheckBody = + "{\"status\": \"unhealthy\"," + + " \"checks\": [" + + " {" + + " \"componentName\": \"component-1\"," + + " \"escapedComponentName\": \"component-1\"," + + " \"status\": \"healthy\"," + + " \"cause\": null" + + " }," + + " {" + + " \"componentName\": \"component-2\"," + + " \"escapedComponentName\": \"component-2\"," + + " \"status\": \"unhealthy\"," + + " \"cause\": \"cause\"" + + "}]}"; + + String retrieveBody = + given() + .queryParam("check", "component-1", "component-2") + .when() + .get() + .then() + .statusCode(HttpStatus.SERVICE_UNAVAILABLE_503) + .extract() + .body().asString(); + + assertThatJson(retrieveBody) + .when(Option.IGNORING_ARRAY_ORDER) + .isEqualTo(healthCheckBody); + } + + @Test + void validateHealthChecksShouldReturnNotFoundWhenOneOfSelectedHealthChecksIsUnknown() { + healthChecks.add(healthCheck(Result.healthy(COMPONENT_NAME_1))); + + given() + .queryParam("check", "component-1", "component-unknown") + .when() + .get() + .then() + .statusCode(HttpStatus.NOT_FOUND_404) + .body("details", is(nullValue())) + .body("type", equalTo("notFound")) + .body("message", equalTo("Components with name component-unknown cannot be found")) + .body("statusCode", is(HttpStatus.NOT_FOUND_404)); + } + + @Test + void validateHealthChecksShouldReturnNotFoundWhenAllSelectedHealthChecksIsUnknown() { + given() + .queryParam("check", "component-unknown-1", "component-unknown-2") + .when() + .get() + .then() + .statusCode(HttpStatus.NOT_FOUND_404) + .body("details", is(nullValue())) + .body("type", equalTo("notFound")) + .body("message", equalTo("Components with name component-unknown-1, component-unknown-2 cannot be found")) + .body("statusCode", is(HttpStatus.NOT_FOUND_404)); + } @Test void performHealthCheckShouldReturnOkWhenHealthCheckIsHealthy() { healthChecks.add(healthCheck(Result.healthy(COMPONENT_NAME_1))); - + given() .pathParam("componentName", COMPONENT_NAME_1.getName()) .when() @@ -333,7 +471,7 @@ void performHealthCheckShouldReturnNotFoundWhenComponentNameIsUnknown() { @Test void performHealthCheckShouldReturnInternalErrorWhenHealthCheckIsDegraded() { healthChecks.add(healthCheck(Result.degraded(COMPONENT_NAME_1, "the cause"))); - + given() .pathParam("componentName", COMPONENT_NAME_1.getName()) .when() @@ -349,7 +487,7 @@ void performHealthCheckShouldReturnInternalErrorWhenHealthCheckIsDegraded() { @Test void performHealthCheckShouldReturnInternalErrorWhenHealthCheckIsUnhealthy() { healthChecks.add(healthCheck(Result.unhealthy(COMPONENT_NAME_1, SAMPLE_CAUSE))); - + given() .pathParam("componentName", COMPONENT_NAME_1.getName()) .when() @@ -365,7 +503,7 @@ void performHealthCheckShouldReturnInternalErrorWhenHealthCheckIsUnhealthy() { @Test void performHealthCheckShouldReturnProperlyEscapedComponentName() { healthChecks.add(healthCheck(Result.healthy(COMPONENT_NAME_3))); - + given() .pathParam("componentName", COMPONENT_NAME_3.getName()) .when() @@ -380,7 +518,7 @@ void performHealthCheckShouldReturnProperlyEscapedComponentName() { @Test void performHealthCheckShouldWorkWithEscapedPathParam() { healthChecks.add(healthCheck(Result.healthy(COMPONENT_NAME_3))); - + given() .urlEncodingEnabled(false) .pathParam("componentName", NAME_3_ESCAPED) @@ -406,6 +544,7 @@ void getHealthchecksShouldReturnEmptyWhenNoHealthChecks() { @Test void getHealthchecksShouldReturnHealthCheckWhenHealthCheckPresent() { healthChecks.add(healthCheck(Result.healthy(COMPONENT_NAME_3))); + when() .get(HealthCheckRoutes.CHECKS) .then() @@ -419,11 +558,11 @@ void getHealthchecksShouldReturnHealthCheckWhenHealthCheckPresent() { void getHealthchecksShouldReturnHealthChecksWhenHealthChecksPresent() { healthChecks.add(healthCheck(Result.healthy(COMPONENT_NAME_2))); healthChecks.add(healthCheck(Result.healthy(COMPONENT_NAME_3))); + when() .get(HealthCheckRoutes.CHECKS) .then() .body("", hasSize(2)) .statusCode(HttpStatus.OK_200); } - } \ No newline at end of file