diff --git a/samples/grpc-server/pom.xml b/samples/grpc-server/pom.xml
index 5d66be6..7f11aaf 100644
--- a/samples/grpc-server/pom.xml
+++ b/samples/grpc-server/pom.xml
@@ -53,6 +53,11 @@
io.grpc
grpc-services
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
io.netty
@@ -177,4 +182,4 @@
-
\ No newline at end of file
+
diff --git a/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerHealthIntegrationTests.java b/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerHealthIntegrationTests.java
new file mode 100644
index 0000000..d1d97cc
--- /dev/null
+++ b/samples/grpc-server/src/test/java/org/springframework/grpc/sample/GrpcServerHealthIntegrationTests.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright 2024-2024 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
+ *
+ * https://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.grpc.sample;
+
+import java.time.Duration;
+
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.actuate.autoconfigure.health.ConditionalOnEnabledHealthIndicator;
+import org.springframework.boot.actuate.health.Health;
+import org.springframework.boot.actuate.health.HealthIndicator;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.grpc.client.GrpcChannelFactory;
+import org.springframework.test.annotation.DirtiesContext;
+
+import io.grpc.StatusRuntimeException;
+import io.grpc.health.v1.HealthCheckRequest;
+import io.grpc.health.v1.HealthCheckResponse.ServingStatus;
+import io.grpc.health.v1.HealthGrpc;
+import io.grpc.health.v1.HealthGrpc.HealthBlockingStub;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration tests for gRPC server health feature.
+ */
+class GrpcServerHealthIntegrationTests {
+
+ @Nested
+ @SpringBootTest(properties = { "spring.grpc.server.health.actuator.health-indicator-paths=custom",
+ "spring.grpc.server.health.actuator.update-initial-delay=3s",
+ "spring.grpc.server.health.actuator.update-rate=3s", "management.health.defaults.enabled=true" })
+ @DirtiesContext
+ class WithActuatorHealthAdapter {
+
+ @Test
+ void healthIndicatorsAdaptedToGprcHealthStatus(@Autowired GrpcChannelFactory channels) {
+ var channel = channels.createChannel("0.0.0.0:0").build();
+ var healthStub = HealthGrpc.newBlockingStub(channel);
+ var serviceName = "custom";
+
+ // initially the status should be SERVING
+ assertThatGrpcHealthStatusIs(healthStub, serviceName, ServingStatus.SERVING, Duration.ofSeconds(4));
+
+ // put the service down and the status should then be NOT_SERVING
+ CustomHealthIndicator.SERVICE_IS_UP = false;
+ assertThatGrpcHealthStatusIs(healthStub, serviceName, ServingStatus.NOT_SERVING, Duration.ofSeconds(4));
+
+ // put the service up and the status should be SERVING
+ CustomHealthIndicator.SERVICE_IS_UP = true;
+ assertThatGrpcHealthStatusIs(healthStub, serviceName, ServingStatus.SERVING, Duration.ofSeconds(4));
+ }
+
+ private void assertThatGrpcHealthStatusIs(HealthBlockingStub healthBlockingStub, String service,
+ ServingStatus expectedStatus, Duration maxWaitTime) {
+ Awaitility.await().atMost(maxWaitTime).ignoreException(StatusRuntimeException.class).untilAsserted(() -> {
+ var healthRequest = HealthCheckRequest.newBuilder().setService(service).build();
+ var healthResponse = healthBlockingStub.check(healthRequest);
+ assertThat(healthResponse.getStatus()).isEqualTo(expectedStatus);
+ var overallHealthRequest = HealthCheckRequest.newBuilder().setService("").build();
+ var overallHealthResponse = healthBlockingStub.check(overallHealthRequest);
+ assertThat(overallHealthResponse.getStatus()).isEqualTo(expectedStatus);
+ });
+ }
+
+ @TestConfiguration
+ static class MyHealthIndicatorsConfig {
+
+ @ConditionalOnEnabledHealthIndicator("custom")
+ @Bean
+ CustomHealthIndicator customHealthIndicator() {
+ return new CustomHealthIndicator();
+ }
+
+ }
+
+ static class CustomHealthIndicator implements HealthIndicator {
+
+ static boolean SERVICE_IS_UP = true;
+
+ @Override
+ public Health health() {
+ return SERVICE_IS_UP ? Health.up().build() : Health.down().build();
+ }
+
+ }
+
+ }
+
+}
diff --git a/spring-grpc-docs/src/main/antora/modules/ROOT/partials/_configprops.adoc b/spring-grpc-docs/src/main/antora/modules/ROOT/partials/_configprops.adoc
index 1b1eb8d..cfb36b6 100644
--- a/spring-grpc-docs/src/main/antora/modules/ROOT/partials/_configprops.adoc
+++ b/spring-grpc-docs/src/main/antora/modules/ROOT/partials/_configprops.adoc
@@ -18,8 +18,11 @@
|spring.grpc.client.default-channel.user-agent | |
|spring.grpc.server.address | | The address to bind to. could be a host:port combination or a pseudo URL like static://host:port. Can not be set if host or port are set independently.
|spring.grpc.server.exception-handling.enabled | `+++true+++` | Whether to enable user-defined global exception handling on the gRPC server.
-|spring.grpc.server.health.actuator.enabled | `+++true+++` | Whether to adapt Actuator health checks into gRPC health checks.
-|spring.grpc.server.health.actuator.endpoints | | List of Actuator health checks to adapt into gRPC health checks.
+|spring.grpc.server.health.actuator.enabled | `+++true+++` | Whether to adapt Actuator health indicators into gRPC health checks.
+|spring.grpc.server.health.actuator.health-indicator-paths | | List of Actuator health indicator paths to adapt into gRPC health checks.
+|spring.grpc.server.health.actuator.update-initial-delay | `+++5s+++` | The initial delay before updating the health status the very first time.
+|spring.grpc.server.health.actuator.update-overall-health | `+++true+++` | Whether to update the overall gRPC server health (the '' service) with the aggregate status of the configured health indicators.
+|spring.grpc.server.health.actuator.update-rate | `+++5s+++` | How often to update the health status.
|spring.grpc.server.health.enabled | `+++true+++` | Whether to auto-configure Health feature on the gRPC server.
|spring.grpc.server.host | `+++*+++` | Server address to bind to. The default is any IP address ('*').
|spring.grpc.server.keep-alive.max-age | | Maximum time a connection may exist before being gracefully terminated (default infinite).
diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServerProperties.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServerProperties.java
index fb28013..91842f5 100644
--- a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServerProperties.java
+++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/GrpcServerProperties.java
@@ -169,14 +169,30 @@ public class GrpcServerProperties {
public static class ActuatorAdapt {
/**
- * Whether to adapt Actuator health checks into gRPC health checks.
+ * Whether to adapt Actuator health indicators into gRPC health checks.
*/
private Boolean enabled = true;
/**
- * List of Actuator health checks to adapt into gRPC health checks.
+ * Whether to update the overall gRPC server health (the '' service) with the
+ * aggregate status of the configured health indicators.
*/
- private List endpoints = new ArrayList<>();
+ private Boolean updateOverallHealth = true;
+
+ /**
+ * How often to update the health status.
+ */
+ private Duration updateRate = Duration.ofSeconds(5);
+
+ /**
+ * The initial delay before updating the health status the very first time.
+ */
+ private Duration updateInitialDelay = Duration.ofSeconds(5);
+
+ /**
+ * List of Actuator health indicator paths to adapt into gRPC health checks.
+ */
+ private List healthIndicatorPaths = new ArrayList<>();
public Boolean getEnabled() {
return this.enabled;
@@ -186,12 +202,36 @@ public class GrpcServerProperties {
this.enabled = enabled;
}
- public List getEndpoints() {
- return this.endpoints;
+ public Boolean getUpdateOverallHealth() {
+ return this.updateOverallHealth;
}
- public void setEndpoints(List endpoints) {
- this.endpoints = endpoints;
+ public void setUpdateOverallHealth(Boolean updateOverallHealth) {
+ this.updateOverallHealth = updateOverallHealth;
+ }
+
+ public Duration getUpdateRate() {
+ return this.updateRate;
+ }
+
+ public void setUpdateRate(Duration updateRate) {
+ this.updateRate = updateRate;
+ }
+
+ public Duration getUpdateInitialDelay() {
+ return this.updateInitialDelay;
+ }
+
+ public void setUpdateInitialDelay(Duration updateInitialDelay) {
+ this.updateInitialDelay = updateInitialDelay;
+ }
+
+ public List getHealthIndicatorPaths() {
+ return this.healthIndicatorPaths;
+ }
+
+ public void setHealthIndicatorPaths(List healthIndicatorPaths) {
+ this.healthIndicatorPaths = healthIndicatorPaths;
}
}
diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapter.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapter.java
index 3757a10..4de6558 100644
--- a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapter.java
+++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapter.java
@@ -16,13 +16,22 @@
package org.springframework.grpc.autoconfigure.server.health;
-import org.springframework.boot.actuate.health.HealthContributor;
-import org.springframework.boot.actuate.health.HealthEndpoint;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.springframework.boot.actuate.health.HealthEndpoint;
+import org.springframework.boot.actuate.health.HealthIndicator;
+import org.springframework.boot.actuate.health.Status;
+import org.springframework.boot.actuate.health.StatusAggregator;
+import org.springframework.core.log.LogAccessor;
+import org.springframework.util.Assert;
+
+import io.grpc.health.v1.HealthCheckResponse.ServingStatus;
import io.grpc.protobuf.services.HealthStatusManager;
/**
- * Adapts {@link HealthContributor Actuator health checks} into gRPC health checks by
+ * Adapts {@link HealthIndicator Actuator health indicators} into gRPC health checks by
* periodically invoking {@link HealthEndpoint health endpoints} and updating the health
* status in gRPC {@link HealthStatusManager}.
*
@@ -30,4 +39,74 @@ import io.grpc.protobuf.services.HealthStatusManager;
*/
public class ActuatorHealthAdapter {
+ private static final String INVALID_INDICATOR_MSG = "Unable to determine health for '%s' - check that your configured health-indicator-paths point to available indicators";
+
+ private final LogAccessor logger = new LogAccessor(getClass());
+
+ private final HealthStatusManager healthStatusManager;
+
+ private final HealthEndpoint healthEndpoint;
+
+ private final StatusAggregator statusAggregator;
+
+ private final boolean updateOverallHealth;
+
+ private final List healthIndicatorPaths;
+
+ protected ActuatorHealthAdapter(HealthStatusManager healthStatusManager, HealthEndpoint healthEndpoint,
+ StatusAggregator statusAggregator, boolean updateOverallHealth, List healthIndicatorPaths) {
+ this.healthStatusManager = healthStatusManager;
+ this.healthEndpoint = healthEndpoint;
+ this.statusAggregator = statusAggregator;
+ this.updateOverallHealth = updateOverallHealth;
+ Assert.notEmpty(healthIndicatorPaths, () -> "at least one health indicator path is required");
+ this.healthIndicatorPaths = healthIndicatorPaths;
+ }
+
+ protected void updateHealthStatus() {
+ var individualStatuses = this.updateIndicatorsHealthStatus();
+ if (this.updateOverallHealth) {
+ this.updateOverallHealthStatus(individualStatuses);
+ }
+ }
+
+ protected Set updateIndicatorsHealthStatus() {
+ Set statuses = new HashSet<>();
+ this.healthIndicatorPaths.forEach((healthIndicatorPath) -> {
+ var healthComponent = this.healthEndpoint.healthForPath(healthIndicatorPath.split("/"));
+ if (healthComponent == null) {
+ this.logger.warn(() -> INVALID_INDICATOR_MSG.formatted(healthIndicatorPath));
+ }
+ else {
+ this.logger.trace(() -> "Actuator returned '%s' for indicator '%s'".formatted(healthComponent,
+ healthIndicatorPath));
+ var actuatorStatus = healthComponent.getStatus();
+ var grpcStatus = toServingStatus(actuatorStatus.getCode());
+ this.healthStatusManager.setStatus(healthIndicatorPath, grpcStatus);
+ this.logger.trace(() -> "Updated gRPC health status to '%s' for service '%s'".formatted(grpcStatus,
+ healthIndicatorPath));
+ statuses.add(actuatorStatus);
+ }
+ });
+ return statuses;
+ }
+
+ protected void updateOverallHealthStatus(Set individualStatuses) {
+ var overallActuatorStatus = this.statusAggregator.getAggregateStatus(individualStatuses);
+ var overallGrpcStatus = toServingStatus(overallActuatorStatus.getCode());
+ this.logger.trace(() -> "Actuator aggregate status '%s' for overall health".formatted(overallActuatorStatus));
+ this.healthStatusManager.setStatus("", overallGrpcStatus);
+ this.logger.trace(() -> "Updated overall gRPC health status to '%s'".formatted(overallGrpcStatus));
+ }
+
+ protected ServingStatus toServingStatus(String actuatorHealthStatusCode) {
+ return switch (actuatorHealthStatusCode) {
+ case "UP" -> ServingStatus.SERVING;
+ case "DOWN" -> ServingStatus.NOT_SERVING;
+ case "OUT_OF_SERVICE" -> ServingStatus.NOT_SERVING;
+ case "UNKNOWN" -> ServingStatus.UNKNOWN;
+ default -> ServingStatus.UNKNOWN;
+ };
+ }
+
}
diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterInvoker.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterInvoker.java
new file mode 100644
index 0000000..4bbfdf7
--- /dev/null
+++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterInvoker.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2023-2024 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
+ *
+ * https://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.grpc.autoconfigure.server.health;
+
+import java.time.Duration;
+import java.time.Instant;
+
+import org.springframework.beans.factory.DisposableBean;
+import org.springframework.beans.factory.InitializingBean;
+import org.springframework.boot.task.SimpleAsyncTaskSchedulerBuilder;
+import org.springframework.scheduling.concurrent.SimpleAsyncTaskScheduler;
+
+/**
+ * Periodically invokes the {@link ActuatorHealthAdapter} in the background.
+ *
+ * @author Chris Bono
+ */
+class ActuatorHealthAdapterInvoker implements InitializingBean, DisposableBean {
+
+ private final ActuatorHealthAdapter healthAdapter;
+
+ private final SimpleAsyncTaskScheduler taskScheduler;
+
+ private final Duration updateInitialDelay;
+
+ private final Duration updateFixedRate;
+
+ ActuatorHealthAdapterInvoker(ActuatorHealthAdapter healthAdapter, SimpleAsyncTaskSchedulerBuilder schedulerBuilder,
+ Duration updateInitialDelay, Duration updateFixedRate) {
+ this.healthAdapter = healthAdapter;
+ this.taskScheduler = schedulerBuilder.threadNamePrefix("healthAdapter-").build();
+ this.updateInitialDelay = updateInitialDelay;
+ this.updateFixedRate = updateFixedRate;
+ }
+
+ @Override
+ public void afterPropertiesSet() {
+ this.taskScheduler.scheduleAtFixedRate(this::updateHealthStatus, Instant.now().plus(this.updateInitialDelay),
+ this.updateFixedRate);
+ }
+
+ @Override
+ public void destroy() {
+ this.taskScheduler.close();
+ }
+
+ void updateHealthStatus() {
+ this.healthAdapter.updateHealthStatus();
+ }
+
+}
diff --git a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/GrpcServerHealthAutoConfiguration.java b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/GrpcServerHealthAutoConfiguration.java
index 99dd5f5..f9e9dfb 100644
--- a/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/GrpcServerHealthAutoConfiguration.java
+++ b/spring-grpc-spring-boot-autoconfigure/src/main/java/org/springframework/grpc/autoconfigure/server/health/GrpcServerHealthAutoConfiguration.java
@@ -18,19 +18,34 @@
package org.springframework.grpc.autoconfigure.server.health;
+import java.util.List;
+
+import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint;
import org.springframework.boot.actuate.health.HealthEndpoint;
+import org.springframework.boot.actuate.health.StatusAggregator;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
-import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.boot.autoconfigure.condition.ConditionMessage;
+import org.springframework.boot.autoconfigure.condition.ConditionOutcome;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.autoconfigure.condition.SpringBootCondition;
+import org.springframework.boot.autoconfigure.task.TaskSchedulingAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.context.properties.bind.BindResult;
+import org.springframework.boot.context.properties.bind.Bindable;
+import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.boot.task.SimpleAsyncTaskSchedulerBuilder;
import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ConditionContext;
+import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
+import org.springframework.core.type.AnnotatedTypeMetadata;
import org.springframework.grpc.autoconfigure.server.GrpcServerFactoryAutoConfiguration;
import org.springframework.grpc.autoconfigure.server.GrpcServerProperties;
+import org.springframework.scheduling.annotation.EnableScheduling;
import io.grpc.BindableService;
import io.grpc.protobuf.services.HealthStatusManager;
@@ -58,18 +73,56 @@ public class GrpcServerHealthAutoConfiguration {
}
@Configuration(proxyBeanMethods = false)
- @AutoConfigureAfter(name = "org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration")
@ConditionalOnClass(HealthEndpoint.class)
- @ConditionalOnBean(HealthEndpoint.class)
+ @ConditionalOnAvailableEndpoint(endpoint = HealthEndpoint.class)
+ @AutoConfigureAfter(value = TaskSchedulingAutoConfiguration.class,
+ name = "org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration")
@ConditionalOnProperty(name = "spring.grpc.server.health.actuator.enabled", havingValue = "true",
matchIfMissing = true)
+ @Conditional(OnHealthIndicatorPathsCondition.class)
@EnableConfigurationProperties(GrpcServerProperties.class)
+ @EnableScheduling
static class ActuatorHealthAdapterConfiguration {
@Bean
+ @ConditionalOnMissingBean
ActuatorHealthAdapter healthAdapter(HealthStatusManager healthStatusManager, HealthEndpoint healthEndpoint,
- GrpcServerProperties serverProperties) {
- return new ActuatorHealthAdapter();
+ StatusAggregator statusAggregator, GrpcServerProperties serverProperties) {
+ return new ActuatorHealthAdapter(healthStatusManager, healthEndpoint, statusAggregator,
+ serverProperties.getHealth().getActuator().getUpdateOverallHealth(),
+ serverProperties.getHealth().getActuator().getHealthIndicatorPaths());
+ }
+
+ @Bean
+ ActuatorHealthAdapterInvoker healthAdapterInvoker(ActuatorHealthAdapter healthAdapter,
+ SimpleAsyncTaskSchedulerBuilder schedulerBuilder, GrpcServerProperties serverProperties) {
+ return new ActuatorHealthAdapterInvoker(healthAdapter, schedulerBuilder,
+ serverProperties.getHealth().getActuator().getUpdateInitialDelay(),
+ serverProperties.getHealth().getActuator().getUpdateRate());
+ }
+
+ }
+
+ /**
+ * Condition to determine if
+ * {@code spring.grpc.server.health.actuator.health-indicator-paths} is specified with
+ * at least one entry.
+ */
+ static class OnHealthIndicatorPathsCondition extends SpringBootCondition {
+
+ @Override
+ public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
+ String propertyName = "spring.grpc.server.health.actuator.health-indicator-paths";
+ BindResult> property = Binder.get(context.getEnvironment())
+ .bind(propertyName, Bindable.listOf(String.class));
+ ConditionMessage.Builder messageBuilder = ConditionMessage
+ .forCondition("Health indicator paths (at least one)");
+ if (property.isBound() && !property.get().isEmpty()) {
+ return ConditionOutcome
+ .match(messageBuilder.because("property %s found with at least one entry".formatted(propertyName)));
+ }
+ return ConditionOutcome.noMatch(
+ messageBuilder.because("property %s not found with at least one entry".formatted(propertyName)));
}
}
diff --git a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/GrpcServerPropertiesTests.java b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/GrpcServerPropertiesTests.java
index 7cd4540..83bd9a9 100644
--- a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/GrpcServerPropertiesTests.java
+++ b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/GrpcServerPropertiesTests.java
@@ -71,7 +71,10 @@ class GrpcServerPropertiesTests {
GrpcServerProperties.Health properties = bindProperties(map).getHealth();
assertThat(properties.getEnabled()).isTrue();
assertThat(properties.getActuator().getEnabled()).isTrue();
- assertThat(properties.getActuator().getEndpoints()).isEmpty();
+ assertThat(properties.getActuator().getHealthIndicatorPaths()).isEmpty();
+ assertThat(properties.getActuator().getUpdateOverallHealth()).isTrue();
+ assertThat(properties.getActuator().getUpdateRate()).isEqualTo(Duration.ofSeconds(5));
+ assertThat(properties.getActuator().getUpdateInitialDelay()).isEqualTo(Duration.ofSeconds(5));
}
@Test
@@ -79,11 +82,17 @@ class GrpcServerPropertiesTests {
Map map = new HashMap<>();
map.put("spring.grpc.server.health.enabled", "false");
map.put("spring.grpc.server.health.actuator.enabled", "false");
- map.put("spring.grpc.server.health.actuator.endpoints", "a,b,c");
+ map.put("spring.grpc.server.health.actuator.health-indicator-paths", "a,b,c");
+ map.put("spring.grpc.server.health.actuator.update-overall-health", "false");
+ map.put("spring.grpc.server.health.actuator.update-rate", "2s");
+ map.put("spring.grpc.server.health.actuator.update-initial-delay", "1m");
GrpcServerProperties.Health properties = bindProperties(map).getHealth();
assertThat(properties.getEnabled()).isFalse();
assertThat(properties.getActuator().getEnabled()).isFalse();
- assertThat(properties.getActuator().getEndpoints()).containsExactly("a", "b", "c");
+ assertThat(properties.getActuator().getHealthIndicatorPaths()).containsExactly("a", "b", "c");
+ assertThat(properties.getActuator().getUpdateOverallHealth()).isFalse();
+ assertThat(properties.getActuator().getUpdateRate()).isEqualTo(Duration.ofSeconds(2));
+ assertThat(properties.getActuator().getUpdateInitialDelay()).isEqualTo(Duration.ofMinutes(1));
}
}
diff --git a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterInvokerTests.java b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterInvokerTests.java
new file mode 100644
index 0000000..1c1dfc1
--- /dev/null
+++ b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterInvokerTests.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2023-2024 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
+ *
+ * https://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.grpc.autoconfigure.server.health;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import java.time.Duration;
+
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.task.SimpleAsyncTaskSchedulerBuilder;
+
+/**
+ * Tests for {@link ActuatorHealthAdapterInvoker}.
+ */
+class ActuatorHealthAdapterInvokerTests {
+
+ @Test
+ void healthAdapterInvokedOnSchedule() {
+ ActuatorHealthAdapter healthAdapter = mock();
+ ActuatorHealthAdapterInvoker invoker = new ActuatorHealthAdapterInvoker(healthAdapter,
+ new SimpleAsyncTaskSchedulerBuilder(), Duration.ofSeconds(5), Duration.ofSeconds(3));
+ try {
+ invoker.afterPropertiesSet();
+ Awaitility.await()
+ .between(Duration.ofSeconds(8), Duration.ofSeconds(10))
+ .untilAsserted(() -> verify(healthAdapter, times(2)).updateHealthStatus());
+ }
+ finally {
+ invoker.destroy();
+ }
+
+ }
+
+}
diff --git a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterTests.java b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterTests.java
new file mode 100644
index 0000000..b7da32b
--- /dev/null
+++ b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/ActuatorHealthAdapterTests.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright 2023-2024 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
+ *
+ * https://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.grpc.autoconfigure.server.health;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anySet;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Set;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+import org.springframework.boot.actuate.health.Health;
+import org.springframework.boot.actuate.health.HealthEndpoint;
+import org.springframework.boot.actuate.health.Status;
+import org.springframework.boot.actuate.health.StatusAggregator;
+
+import io.grpc.health.v1.HealthCheckResponse.ServingStatus;
+import io.grpc.protobuf.services.HealthStatusManager;
+
+/**
+ * Tests for {@link ActuatorHealthAdapter}.
+ */
+class ActuatorHealthAdapterTests {
+
+ private HealthStatusManager mockHealthStatusManager;
+
+ private HealthEndpoint mockHealthEndpoint;
+
+ private StatusAggregator mockStatusAggregator;
+
+ @BeforeEach
+ void prepareMocks() {
+ mockHealthStatusManager = Mockito.mock();
+ mockHealthEndpoint = Mockito.mock();
+ mockStatusAggregator = Mockito.mock();
+ }
+
+ @Test
+ void whenIndicatorPathsFoundStatusIsUpdated() {
+ var service1 = "check1";
+ var service2 = "component2/check2";
+ var service3 = "component3a/component3b/check3";
+ when(mockHealthEndpoint.healthForPath("check1")).thenReturn(Health.up().build());
+ when(mockHealthEndpoint.healthForPath("component2", "check2")).thenReturn(Health.down().build());
+ when(mockHealthEndpoint.healthForPath("component3a", "component3b", "check3"))
+ .thenReturn(Health.unknown().build());
+ when(mockStatusAggregator.getAggregateStatus(anySet())).thenReturn(Status.UNKNOWN);
+ var healthAdapter = new ActuatorHealthAdapter(mockHealthStatusManager, mockHealthEndpoint, mockStatusAggregator,
+ true, List.of(service1, service2, service3));
+ healthAdapter.updateHealthStatus();
+ verify(mockHealthStatusManager).setStatus(service1, ServingStatus.SERVING);
+ verify(mockHealthStatusManager).setStatus(service2, ServingStatus.NOT_SERVING);
+ verify(mockHealthStatusManager).setStatus(service3, ServingStatus.UNKNOWN);
+ ArgumentCaptor> statusesArgCaptor = ArgumentCaptor.captor();
+ verify(mockStatusAggregator).getAggregateStatus(statusesArgCaptor.capture());
+ assertThat(statusesArgCaptor.getValue())
+ .containsExactlyInAnyOrderElementsOf(Set.of(Status.UP, Status.DOWN, Status.UNKNOWN));
+ verify(mockHealthStatusManager).setStatus("", ServingStatus.UNKNOWN);
+ }
+
+ @Test
+ void whenOverallHealthIsFalseOverallStatusIsNotUpdated() {
+ var service1 = "check1";
+ when(mockHealthEndpoint.healthForPath("check1")).thenReturn(Health.up().build());
+ var healthAdapter = new ActuatorHealthAdapter(mockHealthStatusManager, mockHealthEndpoint, mockStatusAggregator,
+ false, List.of(service1));
+ healthAdapter.updateHealthStatus();
+ verifyNoInteractions(mockStatusAggregator);
+ verify(mockHealthStatusManager, never()).setStatus(eq(""), any(ServingStatus.class));
+ }
+
+ @Test
+ void whenIndicatorPathNotFoundStatusIsNotUpdated() {
+ var healthAdapter = new ActuatorHealthAdapter(mockHealthStatusManager, mockHealthEndpoint, mockStatusAggregator,
+ false, List.of("check1"));
+ healthAdapter.updateHealthStatus();
+ verifyNoInteractions(mockHealthStatusManager);
+ }
+
+ @Test
+ void whenNoIndicatorPathsSpecifiedThrowsException() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> new ActuatorHealthAdapter(mockHealthStatusManager, mockHealthEndpoint,
+ mockStatusAggregator, false, List.of()))
+ .withMessage("at least one health indicator path is required");
+ }
+
+ @Nested
+ class ToServingStatusApi {
+
+ private final ActuatorHealthAdapter healthAdapter = new ActuatorHealthAdapter(mockHealthStatusManager,
+ mockHealthEndpoint, mockStatusAggregator, false, List.of("check1"));
+
+ @Test
+ void whenActuatorStatusIsUpThenServingStatusIsUp() {
+ assertThat(this.healthAdapter.toServingStatus(Status.UP.getCode())).isEqualTo(ServingStatus.SERVING);
+ }
+
+ @Test
+ void whenActuatorStatusIsUnknownThenServingStatusIsUnknown() {
+ assertThat(this.healthAdapter.toServingStatus(Status.UNKNOWN.getCode())).isEqualTo(ServingStatus.UNKNOWN);
+ }
+
+ @Test
+ void whenActuatorStatusIsDownThenServingStatusIsNotServing() {
+ assertThat(this.healthAdapter.toServingStatus(Status.DOWN.getCode())).isEqualTo(ServingStatus.NOT_SERVING);
+ }
+
+ @Test
+ void whenActuatorStatusIsOutOfServiceThenServingStatusIsNotServing() {
+ assertThat(this.healthAdapter.toServingStatus(Status.OUT_OF_SERVICE.getCode()))
+ .isEqualTo(ServingStatus.NOT_SERVING);
+ }
+
+ }
+
+}
diff --git a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/GrpcServerHealthAutoConfigurationTests.java b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/GrpcServerHealthAutoConfigurationTests.java
index e575251..71f01ea 100644
--- a/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/GrpcServerHealthAutoConfigurationTests.java
+++ b/spring-grpc-spring-boot-autoconfigure/src/test/java/org/springframework/grpc/autoconfigure/server/health/GrpcServerHealthAutoConfigurationTests.java
@@ -30,6 +30,7 @@ import org.mockito.Mockito;
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.autoconfigure.task.TaskSchedulingAutoConfiguration;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.boot.test.context.FilteredClassLoader;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
@@ -142,41 +143,55 @@ class GrpcServerHealthAutoConfigurationTests {
@Nested
class ActuatorHealthAdapterConfigurationTests {
+ private ApplicationContextRunner validContextRunner() {
+ return GrpcServerHealthAutoConfigurationTests.this.contextRunner()
+ .withPropertyValues("spring.grpc.server.health.actuator.health-indicator-paths=my-indicator")
+ .withConfiguration(AutoConfigurations.of(HealthEndpointAutoConfiguration.class,
+ TaskSchedulingAutoConfiguration.class));
+ }
+
@Test
void adapterIsAutoConfiguredAfterHealthAutoConfiguration() {
- GrpcServerHealthAutoConfigurationTests.this.contextRunner()
- .withConfiguration(AutoConfigurations.of(HealthEndpointAutoConfiguration.class))
+ this.validContextRunner()
.run((context) -> assertThatBeanDefinitionsContainInOrder(context,
HealthEndpointAutoConfiguration.class, ActuatorHealthAdapterConfiguration.class));
}
+ @Test
+ void adapterIsAutoConfiguredAfterTaskSchedulingAutoConfiguration() {
+ this.validContextRunner()
+ .run((context) -> assertThatBeanDefinitionsContainInOrder(context,
+ TaskSchedulingAutoConfiguration.class, ActuatorHealthAdapterConfiguration.class));
+ }
+
@Test
void whenHealthEndpointNotOnClasspathAutoConfigurationIsSkipped() {
GrpcServerHealthAutoConfigurationTests.this.contextRunner()
+ .withConfiguration(AutoConfigurations.of(TaskSchedulingAutoConfiguration.class))
.withClassLoader(new FilteredClassLoader(HealthEndpoint.class))
.run((context) -> assertThat(context)
.doesNotHaveBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class));
}
@Test
- void whenHealthEndpointBeanNotAvailableAutoConfigurationIsSkipped() {
- GrpcServerHealthAutoConfigurationTests.this.contextRunner()
+ void whenHealthEndpointNotAvailableAutoConfigurationIsSkipped() {
+ this.validContextRunner()
+ .withPropertyValues("management.endpoint.health.enabled=false")
+ .withConfiguration(AutoConfigurations.of(TaskSchedulingAutoConfiguration.class))
.run((context) -> assertThat(context)
.doesNotHaveBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class));
}
@Test
void whenActuatorPropertyNotSetAdapterIsAutoConfigured() {
- GrpcServerHealthAutoConfigurationTests.this.contextRunner()
- .withBean("healthEndpoint", HealthEndpoint.class, Mockito::mock)
+ this.validContextRunner()
.run((context) -> assertThat(context)
.hasSingleBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class));
}
@Test
void whenActuatorPropertyIsTrueAdapterIsAutoConfigured() {
- GrpcServerHealthAutoConfigurationTests.this.contextRunner()
- .withBean("healthEndpoint", HealthEndpoint.class, Mockito::mock)
+ this.validContextRunner()
.withPropertyValues("spring.grpc.server.health.actuator.enabled=true")
.run((context) -> assertThat(context)
.hasSingleBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class));
@@ -184,18 +199,44 @@ class GrpcServerHealthAutoConfigurationTests {
@Test
void whenActuatorPropertyIsFalseAdapterIsNotAutoConfigured() {
- GrpcServerHealthAutoConfigurationTests.this.contextRunner()
- .withBean("healthEndpoint", HealthEndpoint.class, Mockito::mock)
+ this.validContextRunner()
.withPropertyValues("spring.grpc.server.health.actuator.enabled=false")
.run((context) -> assertThat(context)
.doesNotHaveBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class));
}
@Test
- void adapterAutoConfiguredAsExpected() {
+ void whenHealthIndicatorPathsIsNotSpecifiedAdapterIsNotAutoConfigured() {
GrpcServerHealthAutoConfigurationTests.this.contextRunner()
- .withBean("healthEndpoint", HealthEndpoint.class, Mockito::mock)
- .run((context) -> assertThat(context).hasSingleBean(ActuatorHealthAdapter.class));
+ .withConfiguration(AutoConfigurations.of(HealthEndpointAutoConfiguration.class,
+ TaskSchedulingAutoConfiguration.class))
+ .run((context) -> assertThat(context)
+ .doesNotHaveBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class));
+ }
+
+ @Test
+ void whenHealthIndicatorPathsIsSpecifiedEmptyAdapterIsNotAutoConfigured() {
+ GrpcServerHealthAutoConfigurationTests.this.contextRunner()
+ .withPropertyValues("spring.grpc.server.health.actuator.health-indicator-paths=")
+ .withConfiguration(AutoConfigurations.of(HealthEndpointAutoConfiguration.class,
+ TaskSchedulingAutoConfiguration.class))
+ .run((context) -> assertThat(context)
+ .doesNotHaveBean(GrpcServerHealthAutoConfiguration.ActuatorHealthAdapterConfiguration.class));
+ }
+
+ @Test
+ void whenHasUserDefinedAdapterDoesNotAutoConfigureBean() {
+ ActuatorHealthAdapter customAdapter = mock();
+ this.validContextRunner()
+ .withBean("customAdapter", ActuatorHealthAdapter.class, () -> customAdapter)
+ .run((context) -> assertThat(context).getBean(ActuatorHealthAdapter.class).isSameAs(customAdapter));
+ }
+
+ @Test
+ void adapterAutoConfiguredAsExpected() {
+ this.validContextRunner()
+ .run((context) -> assertThat(context).hasSingleBean(ActuatorHealthAdapter.class)
+ .hasSingleBean(ActuatorHealthAdapterInvoker.class));
}
}