From b51b997b70bbb7ffc5b2291fd6309acb55dd3f31 Mon Sep 17 00:00:00 2001 From: Stephane Nicoll Date: Thu, 17 May 2018 11:49:05 +0200 Subject: [PATCH] Run specific health check This commit improves the `health` endpoint to run health check for a particular component or, if that component is itself a composite, an instance of that component. Concretely, it is now possible to issue a `GET` on `/actuator/health/{component}` and `/actuator/health/{component}/instance` to retrieve the health of a component or an instance of a composite component, respectively. If details cannot be showed for the current user, any request leads to a 404 and does not invoke the health check at all. Closes gh-8865 --- .../src/main/asciidoc/endpoints/health.adoc | 53 +++- ...loudFoundryWebEndpointDiscovererTests.java | 12 +- ...FoundryActuatorAutoConfigurationTests.java | 16 +- ...FoundryActuatorAutoConfigurationTests.java | 16 +- .../HealthEndpointDocumentationTests.java | 40 ++- .../HealthEndpointWebExtensionTests.java | 269 +++++++++++++++++- .../health/CompositeHealthIndicator.java | 9 + .../boot/actuate/health/HealthEndpoint.java | 40 +++ .../health/HealthEndpointWebExtension.java | 22 +- .../HealthWebEndpointResponseMapper.java | 38 +++ .../actuate/health/HealthEndpointTests.java | 64 ++++- .../HealthWebEndpointResponseMapperTests.java | 125 ++++++++ 12 files changed, 681 insertions(+), 23 deletions(-) create mode 100644 spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/health/HealthWebEndpointResponseMapperTests.java diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/asciidoc/endpoints/health.adoc b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/asciidoc/endpoints/health.adoc index 16bf2fdcfde..8c3d9d24655 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/asciidoc/endpoints/health.adoc +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/asciidoc/endpoints/health.adoc @@ -1,13 +1,11 @@ [[health]] = Health (`health`) - The `health` endpoint provides detailed information about the health of the application. [[health-retrieving]] -== Retrieving the Health - +== Retrieving the Health of the application To retrieve the health of the application, make a `GET` request to `/actuator/health`, as shown in the following curl-based example: @@ -21,9 +19,56 @@ include::{snippets}health/http-response.adoc[] [[health-retrieving-response-structure]] === Response Structure - The response contains details of the health of the application. The following table describes the structure of the response: [cols="2,1,3"] include::{snippets}health/response-fields.adoc[] + + + +[[health-retrieving-component]] +== Retrieving the Health of a component +To retrieve the health of a particular component of the application, make a `GET` request +to `/actuator/health/{component}`, as shown in the following curl-based example: + +include::{snippets}health/component/curl-request.adoc[] + +The resulting response is similar to the following: + +include::{snippets}health/component/http-response.adoc[] + + + +[[health-retrieving-component-response-structure]] +=== Response Structure +The response contains details of the health of a particular component of the application. +The following table describes the structure of the response: + +[cols="2,1,3"] +include::{snippets}health/component/response-fields.adoc[] + + + +[[health-retrieving-component-instance]] +== Retrieving the Health of a component instance +If a particular component consists of multiple instances (as the `broker` indicator in +the example above), the health of a particular instance of that component can be retrieved +by issuing a `GET` request to `/actuator/health/{component}/{instance}`, as shown in the +following curl-based example: + +include::{snippets}health/instance/curl-request.adoc[] + +The resulting response is similar to the following: + +include::{snippets}health/instance/http-response.adoc[] + + + +[[health-retrieving-component-instance-response-structure]] +=== Response Structure +The response contains details of the health of an instance of a particular component of +the application. The following table describes the structure of the response: + +[cols="2,1,3"] +include::{snippets}health/instance/response-fields.adoc[] diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointDiscovererTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointDiscovererTests.java index 31acdc80344..2d381360f5f 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointDiscovererTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointDiscovererTests.java @@ -58,7 +58,7 @@ public class CloudFoundryWebEndpointDiscovererTests { assertThat(endpoints.size()).isEqualTo(2); for (ExposableWebEndpoint endpoint : endpoints) { if (endpoint.getId().equals("health")) { - WebOperation operation = endpoint.getOperations().iterator().next(); + WebOperation operation = findMainReadOperation(endpoint); assertThat(operation.invoke(new InvocationContext( mock(SecurityContext.class), Collections.emptyMap()))) .isEqualTo("cf"); @@ -67,6 +67,16 @@ public class CloudFoundryWebEndpointDiscovererTests { }); } + private WebOperation findMainReadOperation(ExposableWebEndpoint endpoint) { + for (WebOperation operation : endpoint.getOperations()) { + if (operation.getRequestPredicate().getPath().equals("health")) { + return operation; + } + } + throw new IllegalStateException("No main read operation found from " + + endpoint.getOperations()); + } + private void load(Class configuration, Consumer consumer) { this.load((id) -> null, (id) -> id, configuration, consumer); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfigurationTests.java index d1a486a5f11..5b1d82913b0 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfigurationTests.java @@ -284,8 +284,9 @@ public class ReactiveCloudFoundryActuatorAutoConfigurationTests { Collection endpoints = getHandlerMapping( context).getEndpoints(); ExposableWebEndpoint endpoint = endpoints.iterator().next(); - WebOperation webOperation = endpoint.getOperations().iterator() - .next(); + assertThat(endpoint.getOperations()).hasSize(3); + WebOperation webOperation = findOperationWithRequestPath(endpoint, + "health"); Object invoker = ReflectionTestUtils.getField(webOperation, "invoker"); assertThat(ReflectionTestUtils.getField(invoker, "target")) @@ -346,6 +347,17 @@ public class ReactiveCloudFoundryActuatorAutoConfigurationTests { CloudFoundryWebFluxEndpointHandlerMapping.class); } + private WebOperation findOperationWithRequestPath(ExposableWebEndpoint endpoint, + String requestPath) { + for (WebOperation operation : endpoint.getOperations()) { + if (operation.getRequestPredicate().getPath().equals(requestPath)) { + return operation; + } + } + throw new IllegalStateException("No operation found with request path " + + requestPath + " from " + endpoint.getOperations()); + } + @Configuration static class TestConfiguration { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java index 16c4bc60722..8179f965a55 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java @@ -279,8 +279,9 @@ public class CloudFoundryActuatorAutoConfigurationTests { CloudFoundryWebEndpointServletHandlerMapping.class) .getEndpoints(); ExposableWebEndpoint endpoint = endpoints.iterator().next(); - WebOperation webOperation = endpoint.getOperations().iterator() - .next(); + assertThat(endpoint.getOperations()).hasSize(3); + WebOperation webOperation = findOperationWithRequestPath(endpoint, + "health"); Object invoker = ReflectionTestUtils.getField(webOperation, "invoker"); assertThat(ReflectionTestUtils.getField(invoker, "target")) @@ -294,6 +295,17 @@ public class CloudFoundryActuatorAutoConfigurationTests { CloudFoundryWebEndpointServletHandlerMapping.class); } + private WebOperation findOperationWithRequestPath(ExposableWebEndpoint endpoint, + String requestPath) { + for (WebOperation operation : endpoint.getOperations()) { + if (operation.getRequestPredicate().getPath().equals(requestPath)) { + return operation; + } + } + throw new IllegalStateException("No operation found with request path " + + requestPath + " from " + endpoint.getOperations()); + } + @Configuration static class TestConfiguration { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/HealthEndpointDocumentationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/HealthEndpointDocumentationTests.java index 9d66ad8eaca..2e51aabb7c6 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/HealthEndpointDocumentationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/HealthEndpointDocumentationTests.java @@ -17,6 +17,9 @@ package org.springframework.boot.actuate.autoconfigure.endpoint.web.documentation; import java.io.File; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import javax.sql.DataSource; @@ -24,6 +27,8 @@ import javax.sql.DataSource; import org.junit.Test; import org.springframework.boot.actuate.health.CompositeHealthIndicator; +import org.springframework.boot.actuate.health.DefaultHealthIndicatorRegistry; +import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.HealthEndpoint; import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.boot.actuate.health.HealthIndicatorRegistryFactory; @@ -35,6 +40,7 @@ import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.restdocs.payload.FieldDescriptor; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; @@ -47,9 +53,17 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. * Tests for generating documentation describing the {@link HealthEndpoint}. * * @author Andy Wilkinson + * @author Stephane Nicoll */ public class HealthEndpointDocumentationTests extends MockMvcEndpointDocumentationTests { + private static final List componentFields = Arrays.asList( + fieldWithPath("status") + .description("Status of a specific part of the application"), + subsectionWithPath("details").description( + "Details of the health of a specific part of the" + + " application.")); + @Test public void health() throws Exception { this.mockMvc.perform(get("/actuator/health")).andExpect(status().isOk()) @@ -66,6 +80,19 @@ public class HealthEndpointDocumentationTests extends MockMvcEndpointDocumentati + " application.")))); } + @Test + public void healthComponent() throws Exception { + this.mockMvc.perform(get("/actuator/health/db")).andExpect(status().isOk()) + .andDo(document("health/component", responseFields(componentFields))); + } + + @Test + public void healthComponentInstance() throws Exception { + this.mockMvc.perform(get("/actuator/health/broker/us1")) + .andExpect(status().isOk()) + .andDo(document("health/instance", responseFields(componentFields))); + } + @Configuration @Import(BaseDocumentationConfiguration.class) @ImportAutoConfiguration(DataSourceAutoConfiguration.class) @@ -84,11 +111,22 @@ public class HealthEndpointDocumentationTests extends MockMvcEndpointDocumentati } @Bean - public DataSourceHealthIndicator dataSourceHealthIndicator( + public DataSourceHealthIndicator dbHealthIndicator( DataSource dataSource) { return new DataSourceHealthIndicator(dataSource); } + @Bean + public CompositeHealthIndicator brokerHealthIndicator() { + Map indicators = new LinkedHashMap<>(); + indicators.put("us1", () -> Health.up().withDetail("version", "1.0.2") + .build()); + indicators.put("us2", () -> Health.up().withDetail("version", "1.0.4") + .build()); + return new CompositeHealthIndicator(new OrderedHealthAggregator(), + new DefaultHealthIndicatorRegistry(indicators)); + } + } } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionTests.java index c173c53a8ba..13f7e612e9d 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointWebExtensionTests.java @@ -17,18 +17,29 @@ package org.springframework.boot.actuate.autoconfigure.health; import java.security.Principal; +import java.util.HashMap; +import java.util.Map; import org.junit.Test; import org.springframework.boot.actuate.endpoint.SecurityContext; +import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; +import org.springframework.boot.actuate.health.CompositeHealthIndicator; +import org.springframework.boot.actuate.health.DefaultHealthIndicatorRegistry; import org.springframework.boot.actuate.health.Health; import org.springframework.boot.actuate.health.HealthEndpointWebExtension; +import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.boot.actuate.health.HealthWebEndpointResponseMapper; +import org.springframework.boot.actuate.health.OrderedHealthAggregator; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; @@ -42,6 +53,7 @@ import static org.mockito.Mockito.mock; public class HealthEndpointWebExtensionTests { private WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() + .withUserConfiguration(HealthIndicatorsConfiguration.class) .withConfiguration( AutoConfigurations.of(HealthIndicatorAutoConfiguration.class, HealthEndpointAutoConfiguration.class)); @@ -84,7 +96,7 @@ public class HealthEndpointWebExtensionTests { this.contextRunner.run((context) -> { HealthEndpointWebExtension extension = context .getBean(HealthEndpointWebExtension.class); - assertThat(extension.getHealth(mock(SecurityContext.class)).getBody() + assertThat(extension.health(mock(SecurityContext.class)).getBody() .getDetails()).isEmpty(); }); } @@ -96,7 +108,7 @@ public class HealthEndpointWebExtensionTests { .getBean(HealthEndpointWebExtension.class); SecurityContext securityContext = mock(SecurityContext.class); given(securityContext.getPrincipal()).willReturn(mock(Principal.class)); - assertThat(extension.getHealth(securityContext).getBody().getDetails()) + assertThat(extension.health(securityContext).getBody().getDetails()) .isEmpty(); }); } @@ -113,7 +125,7 @@ public class HealthEndpointWebExtensionTests { given(securityContext.getPrincipal()) .willReturn(mock(Principal.class)); assertThat( - extension.getHealth(securityContext).getBody().getDetails()) + extension.health(securityContext).getBody().getDetails()) .isNotEmpty(); }); } @@ -125,7 +137,7 @@ public class HealthEndpointWebExtensionTests { .run((context) -> { HealthEndpointWebExtension extension = context .getBean(HealthEndpointWebExtension.class); - assertThat(extension.getHealth(null).getBody().getDetails()) + assertThat(extension.health(null).getBody().getDetails()) .isNotEmpty(); }); } @@ -137,7 +149,7 @@ public class HealthEndpointWebExtensionTests { .run((context) -> { HealthEndpointWebExtension extension = context .getBean(HealthEndpointWebExtension.class); - assertThat(extension.getHealth(mock(SecurityContext.class)).getBody() + assertThat(extension.health(mock(SecurityContext.class)).getBody() .getDetails()).isEmpty(); }); } @@ -154,7 +166,7 @@ public class HealthEndpointWebExtensionTests { .willReturn(mock(Principal.class)); given(securityContext.isUserInRole("ACTUATOR")).willReturn(false); assertThat( - extension.getHealth(securityContext).getBody().getDetails()) + extension.health(securityContext).getBody().getDetails()) .isEmpty(); }); } @@ -171,11 +183,233 @@ public class HealthEndpointWebExtensionTests { .willReturn(mock(Principal.class)); given(securityContext.isUserInRole("ACTUATOR")).willReturn(true); assertThat( - extension.getHealth(securityContext).getBody().getDetails()) + extension.health(securityContext).getBody().getDetails()) .isNotEmpty(); }); } + @Test + public void unauthenticatedUsersAreNotShownComponentByDefault() { + this.contextRunner.run((context) -> { + HealthEndpointWebExtension extension = context + .getBean(HealthEndpointWebExtension.class); + assertDetailsNotFound(extension.healthForComponent( + mock(SecurityContext.class), "simple")); + }); + } + + @Test + public void authenticatedUsersAreNotShownComponentByDefault() { + this.contextRunner.run((context) -> { + HealthEndpointWebExtension extension = context + .getBean(HealthEndpointWebExtension.class); + SecurityContext securityContext = mock(SecurityContext.class); + given(securityContext.getPrincipal()).willReturn(mock(Principal.class)); + assertDetailsNotFound(extension.healthForComponent(securityContext, + "simple")); + }); + } + + @Test + public void authenticatedUsersWhenAuthorizedCanBeShownComponent() { + this.contextRunner + .withPropertyValues( + "management.endpoint.health.show-details=when-authorized") + .run((context) -> { + HealthEndpointWebExtension extension = context + .getBean(HealthEndpointWebExtension.class); + SecurityContext securityContext = mock(SecurityContext.class); + given(securityContext.getPrincipal()) + .willReturn(mock(Principal.class)); + assertSimpleComponent(extension.healthForComponent( + securityContext, "simple")); + }); + } + + @Test + public void unauthenticatedUsersCanBeShownComponent() { + this.contextRunner + .withPropertyValues("management.endpoint.health.show-details=always") + .run((context) -> { + HealthEndpointWebExtension extension = context + .getBean(HealthEndpointWebExtension.class); + assertSimpleComponent(extension.healthForComponent(null, "simple")); + }); + } + + @Test + public void componentCanBeHiddenFromAuthenticatedUsers() { + this.contextRunner + .withPropertyValues("management.endpoint.health.show-details=never") + .run((context) -> { + HealthEndpointWebExtension extension = context + .getBean(HealthEndpointWebExtension.class); + assertDetailsNotFound(extension.healthForComponent( + mock(SecurityContext.class), "simple")); + }); + } + + @Test + public void componentCanBeHiddenFromUnauthorizedUsers() { + this.contextRunner.withPropertyValues( + "management.endpoint.health.show-details=when-authorized", + "management.endpoint.health.roles=ACTUATOR").run((context) -> { + HealthEndpointWebExtension extension = context + .getBean(HealthEndpointWebExtension.class); + SecurityContext securityContext = mock(SecurityContext.class); + given(securityContext.getPrincipal()) + .willReturn(mock(Principal.class)); + given(securityContext.isUserInRole("ACTUATOR")).willReturn(false); + assertDetailsNotFound(extension.healthForComponent(securityContext, + "simple")); + }); + } + + @Test + public void componentCanBeShownToAuthorizedUsers() { + this.contextRunner.withPropertyValues( + "management.endpoint.health.show-details=when-authorized", + "management.endpoint.health.roles=ACTUATOR").run((context) -> { + HealthEndpointWebExtension extension = context + .getBean(HealthEndpointWebExtension.class); + SecurityContext securityContext = mock(SecurityContext.class); + given(securityContext.getPrincipal()) + .willReturn(mock(Principal.class)); + given(securityContext.isUserInRole("ACTUATOR")).willReturn(true); + assertSimpleComponent(extension.healthForComponent(securityContext, + "simple")); + }); + } + + @Test + public void componentThatDoesNotExistMapTo404() { + this.contextRunner + .withPropertyValues("management.endpoint.health.show-details=always") + .run((context) -> { + HealthEndpointWebExtension extension = context + .getBean(HealthEndpointWebExtension.class); + assertDetailsNotFound(extension.healthForComponent(null, + "does-not-exist")); + }); + } + + @Test + public void unauthenticatedUsersAreNotShownComponentInstanceByDefault() { + this.contextRunner.run((context) -> { + HealthEndpointWebExtension extension = context + .getBean(HealthEndpointWebExtension.class); + assertDetailsNotFound(extension.healthForComponentInstance( + mock(SecurityContext.class), "composite", "one")); + }); + } + + @Test + public void authenticatedUsersAreNotShownComponentInstanceByDefault() { + this.contextRunner.run((context) -> { + HealthEndpointWebExtension extension = context + .getBean(HealthEndpointWebExtension.class); + SecurityContext securityContext = mock(SecurityContext.class); + given(securityContext.getPrincipal()).willReturn(mock(Principal.class)); + assertDetailsNotFound(extension.healthForComponentInstance(securityContext, + "composite", "one")); + }); + } + + @Test + public void authenticatedUsersWhenAuthorizedCanBeShownComponentInstance() { + this.contextRunner + .withPropertyValues( + "management.endpoint.health.show-details=when-authorized") + .run((context) -> { + HealthEndpointWebExtension extension = context + .getBean(HealthEndpointWebExtension.class); + SecurityContext securityContext = mock(SecurityContext.class); + given(securityContext.getPrincipal()) + .willReturn(mock(Principal.class)); + assertSimpleComponent(extension.healthForComponentInstance( + securityContext, "composite", "one")); + }); + } + + @Test + public void unauthenticatedUsersCanBeShownComponentInstance() { + this.contextRunner + .withPropertyValues("management.endpoint.health.show-details=always") + .run((context) -> { + HealthEndpointWebExtension extension = context + .getBean(HealthEndpointWebExtension.class); + assertSimpleComponent(extension.healthForComponentInstance(null, + "composite", "one")); + }); + } + + @Test + public void componentInstanceCanBeHiddenFromAuthenticatedUsers() { + this.contextRunner + .withPropertyValues("management.endpoint.health.show-details=never") + .run((context) -> { + HealthEndpointWebExtension extension = context + .getBean(HealthEndpointWebExtension.class); + assertDetailsNotFound(extension.healthForComponentInstance( + mock(SecurityContext.class), "composite", "one")); + }); + } + + @Test + public void componentInstanceCanBeHiddenFromUnauthorizedUsers() { + this.contextRunner.withPropertyValues( + "management.endpoint.health.show-details=when-authorized", + "management.endpoint.health.roles=ACTUATOR").run((context) -> { + HealthEndpointWebExtension extension = context + .getBean(HealthEndpointWebExtension.class); + SecurityContext securityContext = mock(SecurityContext.class); + given(securityContext.getPrincipal()) + .willReturn(mock(Principal.class)); + given(securityContext.isUserInRole("ACTUATOR")).willReturn(false); + assertDetailsNotFound(extension.healthForComponentInstance(securityContext, + "composite", "one")); + }); + } + + @Test + public void componentInstanceCanBeShownToAuthorizedUsers() { + this.contextRunner.withPropertyValues( + "management.endpoint.health.show-details=when-authorized", + "management.endpoint.health.roles=ACTUATOR").run((context) -> { + HealthEndpointWebExtension extension = context + .getBean(HealthEndpointWebExtension.class); + SecurityContext securityContext = mock(SecurityContext.class); + given(securityContext.getPrincipal()) + .willReturn(mock(Principal.class)); + given(securityContext.isUserInRole("ACTUATOR")).willReturn(true); + assertSimpleComponent(extension.healthForComponentInstance(securityContext, + "composite", "one")); + }); + } + + @Test + public void componentInstanceThatDoesNotExistMapTo404() { + this.contextRunner + .withPropertyValues("management.endpoint.health.show-details=always") + .run((context) -> { + HealthEndpointWebExtension extension = context + .getBean(HealthEndpointWebExtension.class); + assertDetailsNotFound(extension.healthForComponentInstance(null, + "composite", "does-not-exist")); + }); + } + + private void assertDetailsNotFound(WebEndpointResponse response) { + assertThat(response.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value()); + assertThat(response.getBody()).isNull(); + } + + private void assertSimpleComponent(WebEndpointResponse response) { + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(response.getBody().getDetails()).containsOnly( + entry("counter", 42)); + } + @Test public void roleCanBeCustomized() { this.contextRunner.withPropertyValues( @@ -188,9 +422,28 @@ public class HealthEndpointWebExtensionTests { .willReturn(mock(Principal.class)); given(securityContext.isUserInRole("ADMIN")).willReturn(true); assertThat( - extension.getHealth(securityContext).getBody().getDetails()) + extension.health(securityContext).getBody().getDetails()) .isNotEmpty(); }); } + @Configuration + static class HealthIndicatorsConfiguration { + + @Bean + public HealthIndicator simpleHealthIndicator() { + return () -> Health.up().withDetail("counter", 42).build(); + } + + @Bean + public HealthIndicator compositeHealthIndicator() { + Map nestedIndicators = new HashMap<>(); + nestedIndicators.put("one", simpleHealthIndicator()); + nestedIndicators.put("two", () -> Health.up().build()); + return new CompositeHealthIndicator(new OrderedHealthAggregator(), + new DefaultHealthIndicatorRegistry(nestedIndicators)); + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/CompositeHealthIndicator.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/CompositeHealthIndicator.java index c7507c691a0..04cb30f35d0 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/CompositeHealthIndicator.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/CompositeHealthIndicator.java @@ -86,6 +86,15 @@ public class CompositeHealthIndicator implements HealthIndicator { this.registry.register(name, indicator); } + /** + * Return the {@link HealthIndicatorRegistry} of this instance. + * @return the registry of nested {@link HealthIndicator health indicators} + * @since 2.1.0 + */ + public HealthIndicatorRegistry getRegistry() { + return this.registry; + } + @Override public Health health() { Map healths = new LinkedHashMap<>(); diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthEndpoint.java index 69b0ee33553..8004a552025 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthEndpoint.java @@ -18,6 +18,7 @@ package org.springframework.boot.actuate.health; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.actuate.endpoint.annotation.Selector; import org.springframework.util.Assert; /** @@ -26,6 +27,7 @@ import org.springframework.util.Assert; * @author Dave Syer * @author Christian Dupuis * @author Andy Wilkinson + * @author Stephane Nicoll * @since 2.0.0 */ @Endpoint(id = "health") @@ -48,4 +50,42 @@ public class HealthEndpoint { return this.healthIndicator.health(); } + /** + * Return the {@link Health} of a particular component or {@code null} if such + * component does not exist. + * @param component the name of a particular {@link HealthIndicator} + * @return the {@link Health} for the component or {@code null} + */ + @ReadOperation + public Health healthForComponent(@Selector String component) { + HealthIndicator indicator = getNestedHealthIndicator(this.healthIndicator, + component); + return (indicator != null ? indicator.health() : null); + } + + /** + * Return the {@link Health} of a particular {@code instance} managed by the specified + * {@code component} or {@code null} if that particular component is not a + * {@link CompositeHealthIndicator} or if such instance does not exist. + * @param component the name of a particular {@link CompositeHealthIndicator} + * @param instance the name of an instance managed by that component + * @return the {@link Health} for the component instance of {@code null} + */ + @ReadOperation + public Health healthForComponentInstance(@Selector String component, + @Selector String instance) { + HealthIndicator indicator = getNestedHealthIndicator(this.healthIndicator, + component); + HealthIndicator nestedIndicator = getNestedHealthIndicator(indicator, instance); + return (nestedIndicator != null ? nestedIndicator.health() : null); + } + + private HealthIndicator getNestedHealthIndicator(HealthIndicator healthIndicator, + String name) { + if (healthIndicator instanceof CompositeHealthIndicator) { + return ((CompositeHealthIndicator) healthIndicator).getRegistry().get(name); + } + return null; + } + } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthEndpointWebExtension.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthEndpointWebExtension.java index 420db8a56b7..ae3ac45c095 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthEndpointWebExtension.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthEndpointWebExtension.java @@ -16,8 +16,11 @@ package org.springframework.boot.actuate.health; +import java.util.function.Supplier; + import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.actuate.endpoint.annotation.Selector; import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension; @@ -30,6 +33,7 @@ import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExten * @author Phillip Webb * @author EddĂș MelĂ©ndez * @author Madhura Bhave + * @author Stephane Nicoll * @since 2.0.0 */ @EndpointWebExtension(endpoint = HealthEndpoint.class) @@ -46,10 +50,26 @@ public class HealthEndpointWebExtension { } @ReadOperation - public WebEndpointResponse getHealth(SecurityContext securityContext) { + public WebEndpointResponse health(SecurityContext securityContext) { return this.responseMapper.map(this.delegate.health(), securityContext); } + @ReadOperation + public WebEndpointResponse healthForComponent(SecurityContext securityContext, + @Selector String component) { + Supplier health = () -> this.delegate.healthForComponent(component); + return this.responseMapper.mapDetails(health, securityContext); + } + + @ReadOperation + public WebEndpointResponse healthForComponentInstance( + SecurityContext securityContext, @Selector String component, + @Selector String instance) { + Supplier health = () -> this.delegate.healthForComponentInstance( + component, instance); + return this.responseMapper.mapDetails(health, securityContext); + } + public WebEndpointResponse getHealth(SecurityContext securityContext, ShowDetails showDetails) { return this.responseMapper.map(this.delegate.health(), securityContext, diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthWebEndpointResponseMapper.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthWebEndpointResponseMapper.java index b37a459d385..78ada76360a 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthWebEndpointResponseMapper.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthWebEndpointResponseMapper.java @@ -17,6 +17,7 @@ package org.springframework.boot.actuate.health; import java.util.Set; +import java.util.function.Supplier; import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; @@ -43,6 +44,28 @@ public class HealthWebEndpointResponseMapper { this.authorizedRoles = authorizedRoles; } + /** + * Maps the given {@code health} details to a {@link WebEndpointResponse}, honouring + * the mapper's default {@link ShowDetails} using the given {@code securityContext}. + *

+ * If the current user does not have the right to see the details, the + * {@link Supplier} is not invoked and a 404 response is returned instead. + * @param health the provider of health details, invoked if the current user has the + * right to see them + * @param securityContext the security context + * @return the mapped response + */ + public WebEndpointResponse mapDetails(Supplier health, + SecurityContext securityContext) { + if (canSeeDetails(securityContext, this.showDetails)) { + Health healthDetails = health.get(); + if (healthDetails != null) { + return createWebEndpointResponse(healthDetails); + } + } + return new WebEndpointResponse<>(WebEndpointResponse.STATUS_NOT_FOUND); + } + /** * Maps the given {@code health} to a {@link WebEndpointResponse}, honouring the * mapper's default {@link ShowDetails} using the given {@code securityContext}. @@ -71,10 +94,25 @@ public class HealthWebEndpointResponseMapper { || !isUserInRole(securityContext)))) { health = Health.status(health.getStatus()).build(); } + return createWebEndpointResponse(health); + } + + private WebEndpointResponse createWebEndpointResponse(Health health) { Integer status = this.statusHttpMapper.mapStatus(health.getStatus()); return new WebEndpointResponse<>(health, status); } + private boolean canSeeDetails(SecurityContext securityContext, + ShowDetails showDetails) { + if (showDetails == ShowDetails.NEVER + || (showDetails == ShowDetails.WHEN_AUTHORIZED + && (securityContext.getPrincipal() == null + || !isUserInRole(securityContext)))) { + return false; + } + return true; + } + private boolean isUserInRole(SecurityContext securityContext) { if (CollectionUtils.isEmpty(this.authorizedRoles)) { return true; diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/health/HealthEndpointTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/health/HealthEndpointTests.java index e2fd12f3607..ca387000cae 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/health/HealthEndpointTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/health/HealthEndpointTests.java @@ -16,6 +16,7 @@ package org.springframework.boot.actuate.health; +import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -30,16 +31,21 @@ import static org.assertj.core.api.Assertions.entry; * @author Phillip Webb * @author Christian Dupuis * @author Andy Wilkinson + * @author Stephane Nicoll */ public class HealthEndpointTests { + private static final HealthIndicator one = () -> new Health.Builder() + .status(Status.UP).withDetail("first", "1").build(); + + private static final HealthIndicator two = () -> new Health.Builder() + .status(Status.UP).withDetail("second", "2").build(); + @Test public void statusAndFullDetailsAreExposed() { Map healthIndicators = new HashMap<>(); - healthIndicators.put("up", () -> new Health.Builder().status(Status.UP) - .withDetail("first", "1").build()); - healthIndicators.put("upAgain", () -> new Health.Builder().status(Status.UP) - .withDetail("second", "2").build()); + healthIndicators.put("up", one); + healthIndicators.put("upAgain", two); HealthEndpoint endpoint = new HealthEndpoint( createHealthIndicator(healthIndicators)); Health health = endpoint.health(); @@ -51,6 +57,56 @@ public class HealthEndpointTests { assertThat(upAgainHealth.getDetails()).containsOnly(entry("second", "2")); } + @Test + public void statusForComponentIsExposed() { + HealthEndpoint endpoint = new HealthEndpoint(createHealthIndicator( + Collections.singletonMap("test", one))); + Health health = endpoint.healthForComponent("test"); + assertThat(health).isNotNull(); + assertThat(health.getStatus()).isEqualTo(Status.UP); + assertThat(health.getDetails()).containsOnly(entry("first", "1")); + } + + @Test + public void statusForUnknownComponentReturnNull() { + HealthEndpoint endpoint = new HealthEndpoint(createHealthIndicator( + Collections.emptyMap())); + Health health = endpoint.healthForComponent("does-not-exist"); + assertThat(health).isNull(); + } + + @Test + public void statusForComponentInstanceIsExposed() { + CompositeHealthIndicator compositeIndicator = new CompositeHealthIndicator( + new OrderedHealthAggregator(), new DefaultHealthIndicatorRegistry( + Collections.singletonMap("sub", () -> Health.down().build()))); + HealthEndpoint endpoint = new HealthEndpoint(createHealthIndicator( + Collections.singletonMap("test", compositeIndicator))); + Health health = endpoint.healthForComponentInstance("test", "sub"); + assertThat(health).isNotNull(); + assertThat(health.getStatus()).isEqualTo(Status.DOWN); + assertThat(health.getDetails()).isEmpty(); + } + + @Test + public void statusForUnknownComponentInstanceReturnNull() { + CompositeHealthIndicator compositeIndicator = new CompositeHealthIndicator( + new OrderedHealthAggregator(), new DefaultHealthIndicatorRegistry( + Collections.singletonMap("sub", () -> Health.down().build()))); + HealthEndpoint endpoint = new HealthEndpoint(createHealthIndicator( + Collections.singletonMap("test", compositeIndicator))); + Health health = endpoint.healthForComponentInstance("test", "does-not-exist"); + assertThat(health).isNull(); + } + + @Test + public void statusForComponentInstanceThatIsNotACompositeReturnNull() { + HealthEndpoint endpoint = new HealthEndpoint(createHealthIndicator( + Collections.singletonMap("test", () -> Health.up().build()))); + Health health = endpoint.healthForComponentInstance("test", "does-not-exist"); + assertThat(health).isNull(); + } + private HealthIndicator createHealthIndicator( Map healthIndicators) { return new CompositeHealthIndicator(new OrderedHealthAggregator(), diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/health/HealthWebEndpointResponseMapperTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/health/HealthWebEndpointResponseMapperTests.java new file mode 100644 index 00000000000..ef6de57a611 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/health/HealthWebEndpointResponseMapperTests.java @@ -0,0 +1,125 @@ +/* + * Copyright 2012-2018 the original author or authors. + * + * 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.springframework.boot.actuate.health; + +import java.security.Principal; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; + +import org.junit.Test; +import org.mockito.stubbing.Answer; + +import org.springframework.boot.actuate.endpoint.SecurityContext; +import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; +import org.springframework.http.HttpStatus; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; + +/** + * Tests for {@link HealthWebEndpointResponseMapper}. + * + * @author Stephane Nicoll + */ +public class HealthWebEndpointResponseMapperTests { + + private final HealthStatusHttpMapper statusHttpMapper = new HealthStatusHttpMapper(); + + private Set autorizedRoles = Collections.singleton("ACTUATOR"); + + @Test + public void mapDetailsWithDisableDetailsDoesNotInvokeSupplier() { + HealthWebEndpointResponseMapper mapper = createMapper(ShowDetails.NEVER); + Supplier supplier = mockSupplier(); + SecurityContext securityContext = mock(SecurityContext.class); + WebEndpointResponse response = mapper.mapDetails(supplier, + securityContext); + assertThat(response.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value()); + verifyZeroInteractions(supplier); + verifyZeroInteractions(securityContext); + } + + @Test + public void mapDetailsWithUnauthorizedUserDoesNotInvokeSupplier() { + HealthWebEndpointResponseMapper mapper = createMapper(ShowDetails.WHEN_AUTHORIZED); + Supplier supplier = mockSupplier(); + SecurityContext securityContext = mockSecurityContext("USER"); + WebEndpointResponse response = mapper.mapDetails(supplier, + securityContext); + assertThat(response.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value()); + assertThat(response.getBody()).isNull(); + verifyZeroInteractions(supplier); + verify(securityContext).isUserInRole("ACTUATOR"); + } + + @Test + public void mapDetailsWithAuthorizedUserInvokeSupplier() { + HealthWebEndpointResponseMapper mapper = createMapper(ShowDetails.WHEN_AUTHORIZED); + Supplier supplier = mockSupplier(); + given(supplier.get()).willReturn(Health.down().build()); + SecurityContext securityContext = mockSecurityContext("ACTUATOR"); + WebEndpointResponse response = mapper.mapDetails(supplier, + securityContext); + assertThat(response.getStatus()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE.value()); + assertThat(response.getBody().getStatus()).isEqualTo(Status.DOWN); + verify(supplier).get(); + verify(securityContext).isUserInRole("ACTUATOR"); + } + + @Test + public void mapDetailsWithUnavailableHealth() { + HealthWebEndpointResponseMapper mapper = createMapper(ShowDetails.ALWAYS); + Supplier supplier = mockSupplier(); + SecurityContext securityContext = mock(SecurityContext.class); + WebEndpointResponse response = mapper.mapDetails(supplier, + securityContext); + assertThat(response.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value()); + assertThat(response.getBody()).isNull(); + verify(supplier).get(); + verifyZeroInteractions(securityContext); + } + + @SuppressWarnings("unchecked") + private Supplier mockSupplier() { + return mock(Supplier.class); + } + + private SecurityContext mockSecurityContext(String... roles) { + List associatedRoles = Arrays.asList(roles); + SecurityContext securityContext = mock(SecurityContext.class); + given(securityContext.getPrincipal()) + .willReturn(mock(Principal.class)); + given(securityContext.isUserInRole(anyString())).will((Answer) invocation -> { + String expectedRole = invocation.getArgument(0); + return associatedRoles.contains(expectedRole); + }); + return securityContext; + } + + private HealthWebEndpointResponseMapper createMapper(ShowDetails showDetails) { + return new HealthWebEndpointResponseMapper(this.statusHttpMapper, showDetails, + this.autorizedRoles); + } + +}