Commit b51b997b authored by Stephane Nicoll's avatar Stephane Nicoll

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
parent 9f6d3bb2
[[health]] [[health]]
= Health (`health`) = Health (`health`)
The `health` endpoint provides detailed information about the health of the application. The `health` endpoint provides detailed information about the health of the application.
[[health-retrieving]] [[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`, To retrieve the health of the application, make a `GET` request to `/actuator/health`,
as shown in the following curl-based example: as shown in the following curl-based example:
...@@ -21,9 +19,56 @@ include::{snippets}health/http-response.adoc[] ...@@ -21,9 +19,56 @@ include::{snippets}health/http-response.adoc[]
[[health-retrieving-response-structure]] [[health-retrieving-response-structure]]
=== Response Structure === Response Structure
The response contains details of the health of the application. The following table The response contains details of the health of the application. The following table
describes the structure of the response: describes the structure of the response:
[cols="2,1,3"] [cols="2,1,3"]
include::{snippets}health/response-fields.adoc[] 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[]
...@@ -58,7 +58,7 @@ public class CloudFoundryWebEndpointDiscovererTests { ...@@ -58,7 +58,7 @@ public class CloudFoundryWebEndpointDiscovererTests {
assertThat(endpoints.size()).isEqualTo(2); assertThat(endpoints.size()).isEqualTo(2);
for (ExposableWebEndpoint endpoint : endpoints) { for (ExposableWebEndpoint endpoint : endpoints) {
if (endpoint.getId().equals("health")) { if (endpoint.getId().equals("health")) {
WebOperation operation = endpoint.getOperations().iterator().next(); WebOperation operation = findMainReadOperation(endpoint);
assertThat(operation.invoke(new InvocationContext( assertThat(operation.invoke(new InvocationContext(
mock(SecurityContext.class), Collections.emptyMap()))) mock(SecurityContext.class), Collections.emptyMap())))
.isEqualTo("cf"); .isEqualTo("cf");
...@@ -67,6 +67,16 @@ public class CloudFoundryWebEndpointDiscovererTests { ...@@ -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, private void load(Class<?> configuration,
Consumer<CloudFoundryWebEndpointDiscoverer> consumer) { Consumer<CloudFoundryWebEndpointDiscoverer> consumer) {
this.load((id) -> null, (id) -> id, configuration, consumer); this.load((id) -> null, (id) -> id, configuration, consumer);
......
...@@ -284,8 +284,9 @@ public class ReactiveCloudFoundryActuatorAutoConfigurationTests { ...@@ -284,8 +284,9 @@ public class ReactiveCloudFoundryActuatorAutoConfigurationTests {
Collection<ExposableWebEndpoint> endpoints = getHandlerMapping( Collection<ExposableWebEndpoint> endpoints = getHandlerMapping(
context).getEndpoints(); context).getEndpoints();
ExposableWebEndpoint endpoint = endpoints.iterator().next(); ExposableWebEndpoint endpoint = endpoints.iterator().next();
WebOperation webOperation = endpoint.getOperations().iterator() assertThat(endpoint.getOperations()).hasSize(3);
.next(); WebOperation webOperation = findOperationWithRequestPath(endpoint,
"health");
Object invoker = ReflectionTestUtils.getField(webOperation, Object invoker = ReflectionTestUtils.getField(webOperation,
"invoker"); "invoker");
assertThat(ReflectionTestUtils.getField(invoker, "target")) assertThat(ReflectionTestUtils.getField(invoker, "target"))
...@@ -346,6 +347,17 @@ public class ReactiveCloudFoundryActuatorAutoConfigurationTests { ...@@ -346,6 +347,17 @@ public class ReactiveCloudFoundryActuatorAutoConfigurationTests {
CloudFoundryWebFluxEndpointHandlerMapping.class); 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 @Configuration
static class TestConfiguration { static class TestConfiguration {
......
...@@ -279,8 +279,9 @@ public class CloudFoundryActuatorAutoConfigurationTests { ...@@ -279,8 +279,9 @@ public class CloudFoundryActuatorAutoConfigurationTests {
CloudFoundryWebEndpointServletHandlerMapping.class) CloudFoundryWebEndpointServletHandlerMapping.class)
.getEndpoints(); .getEndpoints();
ExposableWebEndpoint endpoint = endpoints.iterator().next(); ExposableWebEndpoint endpoint = endpoints.iterator().next();
WebOperation webOperation = endpoint.getOperations().iterator() assertThat(endpoint.getOperations()).hasSize(3);
.next(); WebOperation webOperation = findOperationWithRequestPath(endpoint,
"health");
Object invoker = ReflectionTestUtils.getField(webOperation, Object invoker = ReflectionTestUtils.getField(webOperation,
"invoker"); "invoker");
assertThat(ReflectionTestUtils.getField(invoker, "target")) assertThat(ReflectionTestUtils.getField(invoker, "target"))
...@@ -294,6 +295,17 @@ public class CloudFoundryActuatorAutoConfigurationTests { ...@@ -294,6 +295,17 @@ public class CloudFoundryActuatorAutoConfigurationTests {
CloudFoundryWebEndpointServletHandlerMapping.class); 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 @Configuration
static class TestConfiguration { static class TestConfiguration {
......
...@@ -17,6 +17,9 @@ ...@@ -17,6 +17,9 @@
package org.springframework.boot.actuate.autoconfigure.endpoint.web.documentation; package org.springframework.boot.actuate.autoconfigure.endpoint.web.documentation;
import java.io.File; import java.io.File;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import javax.sql.DataSource; import javax.sql.DataSource;
...@@ -24,6 +27,8 @@ import javax.sql.DataSource; ...@@ -24,6 +27,8 @@ import javax.sql.DataSource;
import org.junit.Test; import org.junit.Test;
import org.springframework.boot.actuate.health.CompositeHealthIndicator; 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.HealthEndpoint;
import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.boot.actuate.health.HealthIndicatorRegistryFactory; import org.springframework.boot.actuate.health.HealthIndicatorRegistryFactory;
...@@ -35,6 +40,7 @@ import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; ...@@ -35,6 +40,7 @@ import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import; 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.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
...@@ -47,9 +53,17 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. ...@@ -47,9 +53,17 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
* Tests for generating documentation describing the {@link HealthEndpoint}. * Tests for generating documentation describing the {@link HealthEndpoint}.
* *
* @author Andy Wilkinson * @author Andy Wilkinson
* @author Stephane Nicoll
*/ */
public class HealthEndpointDocumentationTests extends MockMvcEndpointDocumentationTests { public class HealthEndpointDocumentationTests extends MockMvcEndpointDocumentationTests {
private static final List<FieldDescriptor> 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 @Test
public void health() throws Exception { public void health() throws Exception {
this.mockMvc.perform(get("/actuator/health")).andExpect(status().isOk()) this.mockMvc.perform(get("/actuator/health")).andExpect(status().isOk())
...@@ -66,6 +80,19 @@ public class HealthEndpointDocumentationTests extends MockMvcEndpointDocumentati ...@@ -66,6 +80,19 @@ public class HealthEndpointDocumentationTests extends MockMvcEndpointDocumentati
+ " application.")))); + " 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 @Configuration
@Import(BaseDocumentationConfiguration.class) @Import(BaseDocumentationConfiguration.class)
@ImportAutoConfiguration(DataSourceAutoConfiguration.class) @ImportAutoConfiguration(DataSourceAutoConfiguration.class)
...@@ -84,11 +111,22 @@ public class HealthEndpointDocumentationTests extends MockMvcEndpointDocumentati ...@@ -84,11 +111,22 @@ public class HealthEndpointDocumentationTests extends MockMvcEndpointDocumentati
} }
@Bean @Bean
public DataSourceHealthIndicator dataSourceHealthIndicator( public DataSourceHealthIndicator dbHealthIndicator(
DataSource dataSource) { DataSource dataSource) {
return new DataSourceHealthIndicator(dataSource); return new DataSourceHealthIndicator(dataSource);
} }
@Bean
public CompositeHealthIndicator brokerHealthIndicator() {
Map<String, HealthIndicator> 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));
}
} }
} }
...@@ -17,18 +17,29 @@ ...@@ -17,18 +17,29 @@
package org.springframework.boot.actuate.autoconfigure.health; package org.springframework.boot.actuate.autoconfigure.health;
import java.security.Principal; import java.security.Principal;
import java.util.HashMap;
import java.util.Map;
import org.junit.Test; import org.junit.Test;
import org.springframework.boot.actuate.endpoint.SecurityContext; 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.Health;
import org.springframework.boot.actuate.health.HealthEndpointWebExtension; 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.HealthWebEndpointResponseMapper;
import org.springframework.boot.actuate.health.OrderedHealthAggregator;
import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner; 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 org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat; 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.BDDMockito.given;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
...@@ -42,6 +53,7 @@ import static org.mockito.Mockito.mock; ...@@ -42,6 +53,7 @@ import static org.mockito.Mockito.mock;
public class HealthEndpointWebExtensionTests { public class HealthEndpointWebExtensionTests {
private WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() private WebApplicationContextRunner contextRunner = new WebApplicationContextRunner()
.withUserConfiguration(HealthIndicatorsConfiguration.class)
.withConfiguration( .withConfiguration(
AutoConfigurations.of(HealthIndicatorAutoConfiguration.class, AutoConfigurations.of(HealthIndicatorAutoConfiguration.class,
HealthEndpointAutoConfiguration.class)); HealthEndpointAutoConfiguration.class));
...@@ -84,7 +96,7 @@ public class HealthEndpointWebExtensionTests { ...@@ -84,7 +96,7 @@ public class HealthEndpointWebExtensionTests {
this.contextRunner.run((context) -> { this.contextRunner.run((context) -> {
HealthEndpointWebExtension extension = context HealthEndpointWebExtension extension = context
.getBean(HealthEndpointWebExtension.class); .getBean(HealthEndpointWebExtension.class);
assertThat(extension.getHealth(mock(SecurityContext.class)).getBody() assertThat(extension.health(mock(SecurityContext.class)).getBody()
.getDetails()).isEmpty(); .getDetails()).isEmpty();
}); });
} }
...@@ -96,7 +108,7 @@ public class HealthEndpointWebExtensionTests { ...@@ -96,7 +108,7 @@ public class HealthEndpointWebExtensionTests {
.getBean(HealthEndpointWebExtension.class); .getBean(HealthEndpointWebExtension.class);
SecurityContext securityContext = mock(SecurityContext.class); SecurityContext securityContext = mock(SecurityContext.class);
given(securityContext.getPrincipal()).willReturn(mock(Principal.class)); given(securityContext.getPrincipal()).willReturn(mock(Principal.class));
assertThat(extension.getHealth(securityContext).getBody().getDetails()) assertThat(extension.health(securityContext).getBody().getDetails())
.isEmpty(); .isEmpty();
}); });
} }
...@@ -113,7 +125,7 @@ public class HealthEndpointWebExtensionTests { ...@@ -113,7 +125,7 @@ public class HealthEndpointWebExtensionTests {
given(securityContext.getPrincipal()) given(securityContext.getPrincipal())
.willReturn(mock(Principal.class)); .willReturn(mock(Principal.class));
assertThat( assertThat(
extension.getHealth(securityContext).getBody().getDetails()) extension.health(securityContext).getBody().getDetails())
.isNotEmpty(); .isNotEmpty();
}); });
} }
...@@ -125,7 +137,7 @@ public class HealthEndpointWebExtensionTests { ...@@ -125,7 +137,7 @@ public class HealthEndpointWebExtensionTests {
.run((context) -> { .run((context) -> {
HealthEndpointWebExtension extension = context HealthEndpointWebExtension extension = context
.getBean(HealthEndpointWebExtension.class); .getBean(HealthEndpointWebExtension.class);
assertThat(extension.getHealth(null).getBody().getDetails()) assertThat(extension.health(null).getBody().getDetails())
.isNotEmpty(); .isNotEmpty();
}); });
} }
...@@ -137,7 +149,7 @@ public class HealthEndpointWebExtensionTests { ...@@ -137,7 +149,7 @@ public class HealthEndpointWebExtensionTests {
.run((context) -> { .run((context) -> {
HealthEndpointWebExtension extension = context HealthEndpointWebExtension extension = context
.getBean(HealthEndpointWebExtension.class); .getBean(HealthEndpointWebExtension.class);
assertThat(extension.getHealth(mock(SecurityContext.class)).getBody() assertThat(extension.health(mock(SecurityContext.class)).getBody()
.getDetails()).isEmpty(); .getDetails()).isEmpty();
}); });
} }
...@@ -154,7 +166,7 @@ public class HealthEndpointWebExtensionTests { ...@@ -154,7 +166,7 @@ public class HealthEndpointWebExtensionTests {
.willReturn(mock(Principal.class)); .willReturn(mock(Principal.class));
given(securityContext.isUserInRole("ACTUATOR")).willReturn(false); given(securityContext.isUserInRole("ACTUATOR")).willReturn(false);
assertThat( assertThat(
extension.getHealth(securityContext).getBody().getDetails()) extension.health(securityContext).getBody().getDetails())
.isEmpty(); .isEmpty();
}); });
} }
...@@ -171,11 +183,233 @@ public class HealthEndpointWebExtensionTests { ...@@ -171,11 +183,233 @@ public class HealthEndpointWebExtensionTests {
.willReturn(mock(Principal.class)); .willReturn(mock(Principal.class));
given(securityContext.isUserInRole("ACTUATOR")).willReturn(true); given(securityContext.isUserInRole("ACTUATOR")).willReturn(true);
assertThat( assertThat(
extension.getHealth(securityContext).getBody().getDetails()) extension.health(securityContext).getBody().getDetails())
.isNotEmpty(); .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<Health> response) {
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
assertThat(response.getBody().getDetails()).containsOnly(
entry("counter", 42));
}
@Test @Test
public void roleCanBeCustomized() { public void roleCanBeCustomized() {
this.contextRunner.withPropertyValues( this.contextRunner.withPropertyValues(
...@@ -188,9 +422,28 @@ public class HealthEndpointWebExtensionTests { ...@@ -188,9 +422,28 @@ public class HealthEndpointWebExtensionTests {
.willReturn(mock(Principal.class)); .willReturn(mock(Principal.class));
given(securityContext.isUserInRole("ADMIN")).willReturn(true); given(securityContext.isUserInRole("ADMIN")).willReturn(true);
assertThat( assertThat(
extension.getHealth(securityContext).getBody().getDetails()) extension.health(securityContext).getBody().getDetails())
.isNotEmpty(); .isNotEmpty();
}); });
} }
@Configuration
static class HealthIndicatorsConfiguration {
@Bean
public HealthIndicator simpleHealthIndicator() {
return () -> Health.up().withDetail("counter", 42).build();
}
@Bean
public HealthIndicator compositeHealthIndicator() {
Map<String, HealthIndicator> nestedIndicators = new HashMap<>();
nestedIndicators.put("one", simpleHealthIndicator());
nestedIndicators.put("two", () -> Health.up().build());
return new CompositeHealthIndicator(new OrderedHealthAggregator(),
new DefaultHealthIndicatorRegistry(nestedIndicators));
}
}
} }
...@@ -86,6 +86,15 @@ public class CompositeHealthIndicator implements HealthIndicator { ...@@ -86,6 +86,15 @@ public class CompositeHealthIndicator implements HealthIndicator {
this.registry.register(name, indicator); 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 @Override
public Health health() { public Health health() {
Map<String, Health> healths = new LinkedHashMap<>(); Map<String, Health> healths = new LinkedHashMap<>();
......
...@@ -18,6 +18,7 @@ package org.springframework.boot.actuate.health; ...@@ -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.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.Selector;
import org.springframework.util.Assert; import org.springframework.util.Assert;
/** /**
...@@ -26,6 +27,7 @@ import org.springframework.util.Assert; ...@@ -26,6 +27,7 @@ import org.springframework.util.Assert;
* @author Dave Syer * @author Dave Syer
* @author Christian Dupuis * @author Christian Dupuis
* @author Andy Wilkinson * @author Andy Wilkinson
* @author Stephane Nicoll
* @since 2.0.0 * @since 2.0.0
*/ */
@Endpoint(id = "health") @Endpoint(id = "health")
...@@ -48,4 +50,42 @@ public class HealthEndpoint { ...@@ -48,4 +50,42 @@ public class HealthEndpoint {
return this.healthIndicator.health(); 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;
}
} }
...@@ -16,8 +16,11 @@ ...@@ -16,8 +16,11 @@
package org.springframework.boot.actuate.health; package org.springframework.boot.actuate.health;
import java.util.function.Supplier;
import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.boot.actuate.endpoint.SecurityContext;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; 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.WebEndpointResponse;
import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension; import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension;
...@@ -30,6 +33,7 @@ import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExten ...@@ -30,6 +33,7 @@ import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExten
* @author Phillip Webb * @author Phillip Webb
* @author Eddú Meléndez * @author Eddú Meléndez
* @author Madhura Bhave * @author Madhura Bhave
* @author Stephane Nicoll
* @since 2.0.0 * @since 2.0.0
*/ */
@EndpointWebExtension(endpoint = HealthEndpoint.class) @EndpointWebExtension(endpoint = HealthEndpoint.class)
...@@ -46,10 +50,26 @@ public class HealthEndpointWebExtension { ...@@ -46,10 +50,26 @@ public class HealthEndpointWebExtension {
} }
@ReadOperation @ReadOperation
public WebEndpointResponse<Health> getHealth(SecurityContext securityContext) { public WebEndpointResponse<Health> health(SecurityContext securityContext) {
return this.responseMapper.map(this.delegate.health(), securityContext); return this.responseMapper.map(this.delegate.health(), securityContext);
} }
@ReadOperation
public WebEndpointResponse<Health> healthForComponent(SecurityContext securityContext,
@Selector String component) {
Supplier<Health> health = () -> this.delegate.healthForComponent(component);
return this.responseMapper.mapDetails(health, securityContext);
}
@ReadOperation
public WebEndpointResponse<Health> healthForComponentInstance(
SecurityContext securityContext, @Selector String component,
@Selector String instance) {
Supplier<Health> health = () -> this.delegate.healthForComponentInstance(
component, instance);
return this.responseMapper.mapDetails(health, securityContext);
}
public WebEndpointResponse<Health> getHealth(SecurityContext securityContext, public WebEndpointResponse<Health> getHealth(SecurityContext securityContext,
ShowDetails showDetails) { ShowDetails showDetails) {
return this.responseMapper.map(this.delegate.health(), securityContext, return this.responseMapper.map(this.delegate.health(), securityContext,
......
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
package org.springframework.boot.actuate.health; package org.springframework.boot.actuate.health;
import java.util.Set; import java.util.Set;
import java.util.function.Supplier;
import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.boot.actuate.endpoint.SecurityContext;
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
...@@ -43,6 +44,28 @@ public class HealthWebEndpointResponseMapper { ...@@ -43,6 +44,28 @@ public class HealthWebEndpointResponseMapper {
this.authorizedRoles = authorizedRoles; 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}.
* <p>
* 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<Health> mapDetails(Supplier<Health> 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 * Maps the given {@code health} to a {@link WebEndpointResponse}, honouring the
* mapper's default {@link ShowDetails} using the given {@code securityContext}. * mapper's default {@link ShowDetails} using the given {@code securityContext}.
...@@ -71,10 +94,25 @@ public class HealthWebEndpointResponseMapper { ...@@ -71,10 +94,25 @@ public class HealthWebEndpointResponseMapper {
|| !isUserInRole(securityContext)))) { || !isUserInRole(securityContext)))) {
health = Health.status(health.getStatus()).build(); health = Health.status(health.getStatus()).build();
} }
return createWebEndpointResponse(health);
}
private WebEndpointResponse<Health> createWebEndpointResponse(Health health) {
Integer status = this.statusHttpMapper.mapStatus(health.getStatus()); Integer status = this.statusHttpMapper.mapStatus(health.getStatus());
return new WebEndpointResponse<>(health, status); 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) { private boolean isUserInRole(SecurityContext securityContext) {
if (CollectionUtils.isEmpty(this.authorizedRoles)) { if (CollectionUtils.isEmpty(this.authorizedRoles)) {
return true; return true;
......
...@@ -16,6 +16,7 @@ ...@@ -16,6 +16,7 @@
package org.springframework.boot.actuate.health; package org.springframework.boot.actuate.health;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
...@@ -30,16 +31,21 @@ import static org.assertj.core.api.Assertions.entry; ...@@ -30,16 +31,21 @@ import static org.assertj.core.api.Assertions.entry;
* @author Phillip Webb * @author Phillip Webb
* @author Christian Dupuis * @author Christian Dupuis
* @author Andy Wilkinson * @author Andy Wilkinson
* @author Stephane Nicoll
*/ */
public class HealthEndpointTests { 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 @Test
public void statusAndFullDetailsAreExposed() { public void statusAndFullDetailsAreExposed() {
Map<String, HealthIndicator> healthIndicators = new HashMap<>(); Map<String, HealthIndicator> healthIndicators = new HashMap<>();
healthIndicators.put("up", () -> new Health.Builder().status(Status.UP) healthIndicators.put("up", one);
.withDetail("first", "1").build()); healthIndicators.put("upAgain", two);
healthIndicators.put("upAgain", () -> new Health.Builder().status(Status.UP)
.withDetail("second", "2").build());
HealthEndpoint endpoint = new HealthEndpoint( HealthEndpoint endpoint = new HealthEndpoint(
createHealthIndicator(healthIndicators)); createHealthIndicator(healthIndicators));
Health health = endpoint.health(); Health health = endpoint.health();
...@@ -51,6 +57,56 @@ public class HealthEndpointTests { ...@@ -51,6 +57,56 @@ public class HealthEndpointTests {
assertThat(upAgainHealth.getDetails()).containsOnly(entry("second", "2")); 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( private HealthIndicator createHealthIndicator(
Map<String, HealthIndicator> healthIndicators) { Map<String, HealthIndicator> healthIndicators) {
return new CompositeHealthIndicator(new OrderedHealthAggregator(), return new CompositeHealthIndicator(new OrderedHealthAggregator(),
......
/*
* 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<String> autorizedRoles = Collections.singleton("ACTUATOR");
@Test
public void mapDetailsWithDisableDetailsDoesNotInvokeSupplier() {
HealthWebEndpointResponseMapper mapper = createMapper(ShowDetails.NEVER);
Supplier<Health> supplier = mockSupplier();
SecurityContext securityContext = mock(SecurityContext.class);
WebEndpointResponse<Health> 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<Health> supplier = mockSupplier();
SecurityContext securityContext = mockSecurityContext("USER");
WebEndpointResponse<Health> 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<Health> supplier = mockSupplier();
given(supplier.get()).willReturn(Health.down().build());
SecurityContext securityContext = mockSecurityContext("ACTUATOR");
WebEndpointResponse<Health> 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<Health> supplier = mockSupplier();
SecurityContext securityContext = mock(SecurityContext.class);
WebEndpointResponse<Health> 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<Health> mockSupplier() {
return mock(Supplier.class);
}
private SecurityContext mockSecurityContext(String... roles) {
List<String> associatedRoles = Arrays.asList(roles);
SecurityContext securityContext = mock(SecurityContext.class);
given(securityContext.getPrincipal())
.willReturn(mock(Principal.class));
given(securityContext.isUserInRole(anyString())).will((Answer<Boolean>) 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);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment