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`)
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[]
......@@ -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<CloudFoundryWebEndpointDiscoverer> consumer) {
this.load((id) -> null, (id) -> id, configuration, consumer);
......
......@@ -284,8 +284,9 @@ public class ReactiveCloudFoundryActuatorAutoConfigurationTests {
Collection<ExposableWebEndpoint> 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 {
......
......@@ -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 {
......
......@@ -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<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
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<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));
}
}
}
......@@ -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<String, Health> healths = new LinkedHashMap<>();
......
......@@ -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;
}
}
......@@ -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<Health> getHealth(SecurityContext securityContext) {
public WebEndpointResponse<Health> health(SecurityContext 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,
ShowDetails showDetails) {
return this.responseMapper.map(this.delegate.health(), securityContext,
......
......@@ -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}.
* <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
* 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<Health> 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;
......
......@@ -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<String, HealthIndicator> 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<String, HealthIndicator> healthIndicators) {
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