From 67ac57fb77560dcd86ac100da5ba318869a73b58 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Sun, 22 Feb 2026 11:02:36 -0800 Subject: [PATCH] Add Spring gRPC server health check support Add integration between Spring Boot's health module and gRPC health checking service support. Closes gh-49048 Co-authored-by: Chris Bono --- module/spring-boot-grpc-server/build.gradle | 3 + ...oConfiguredHealthCheckedGrpcComponent.java | 59 +++ ...ConfiguredHealthCheckedGrpcComponents.java | 144 ++++++ .../GrpcServerHealthAutoConfiguration.java | 142 ++++++ .../health/GrpcServerHealthProperties.java | 248 ++++++++++ .../health/GrpcServerHealthScheduler.java | 69 +++ ...erverHealthSchedulerAutoConfiguration.java | 58 +++ .../autoconfigure/health/package-info.java | 23 + .../grpc/server/health/GrpcServerHealth.java | 122 +++++ .../health/HealthCheckedGrpcComponent.java | 46 ++ .../health/HealthCheckedGrpcComponents.java | 54 +++ .../health/SimpleServingStatusMapper.java | 79 +++ .../server/health/SimpleStatusAggregator.java | 74 +++ .../grpc/server/health/StatusAggregator.java | 95 ++++ .../boot/grpc/server/health/StatusMapper.java | 62 +++ .../boot/grpc/server/health/package-info.java | 23 + ...ot.autoconfigure.AutoConfiguration.imports | 2 + ...iguredHealthCheckedGrpcComponentTests.java | 75 +++ ...guredHealthCheckedGrpcComponentsTests.java | 437 +++++++++++++++++ ...rpcServerHealthAutoConfigurationTests.java | 458 ++++++++++++++++++ .../GrpcServerHealthSchedulerTests.java | 79 +++ .../server/health/GrpcServerHealthTests.java | 293 +++++++++++ .../server/health/StatusAggregatorTests.java | 71 +++ .../grpc/server/health/StatusMapperTests.java | 64 +++ 24 files changed, 2780 insertions(+) create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponent.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponents.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthProperties.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthScheduler.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthSchedulerAutoConfiguration.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/package-info.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/GrpcServerHealth.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/HealthCheckedGrpcComponent.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/HealthCheckedGrpcComponents.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/SimpleServingStatusMapper.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/SimpleStatusAggregator.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/StatusAggregator.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/StatusMapper.java create mode 100644 module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/package-info.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponentTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponentsTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfigurationTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthSchedulerTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/GrpcServerHealthTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/StatusAggregatorTests.java create mode 100644 module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/StatusMapperTests.java diff --git a/module/spring-boot-grpc-server/build.gradle b/module/spring-boot-grpc-server/build.gradle index cd26fcf32e5..3f730227c6a 100644 --- a/module/spring-boot-grpc-server/build.gradle +++ b/module/spring-boot-grpc-server/build.gradle @@ -29,6 +29,9 @@ dependencies { api("org.springframework.grpc:spring-grpc-core") optional(project(":core:spring-boot-autoconfigure")) + optional(project(":module:spring-boot-health")) + optional("com.fasterxml.jackson.core:jackson-annotations") + optional("io.projectreactor:reactor-core") optional("io.grpc:grpc-servlet-jakarta") optional("io.grpc:grpc-services") optional("io.grpc:grpc-netty") diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponent.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponent.java new file mode 100644 index 00000000000..b523e6113d3 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponent.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure.health; + +import org.springframework.boot.grpc.server.health.HealthCheckedGrpcComponent; +import org.springframework.boot.grpc.server.health.StatusAggregator; +import org.springframework.boot.grpc.server.health.StatusMapper; +import org.springframework.boot.health.autoconfigure.contributor.HealthContributorMembership; + +/** + * Auto-configured {@link HealthCheckedGrpcComponent}. + * + * @author Phillip Webb + */ +class AutoConfiguredHealthCheckedGrpcComponent implements HealthCheckedGrpcComponent { + + private final HealthContributorMembership membership; + + private final StatusAggregator statusAggregator; + + private final StatusMapper statusMapper; + + AutoConfiguredHealthCheckedGrpcComponent(HealthContributorMembership membership, StatusAggregator statusAggregator, + StatusMapper statusMapper) { + this.membership = membership; + this.statusAggregator = statusAggregator; + this.statusMapper = statusMapper; + } + + @Override + public boolean isMember(String healthContributorName) { + return this.membership.isMember(healthContributorName); + } + + @Override + public StatusAggregator getStatusAggregator() { + return this.statusAggregator; + } + + @Override + public StatusMapper getStatusMapper() { + return this.statusMapper; + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponents.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponents.java new file mode 100644 index 00000000000..98248c2b885 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponents.java @@ -0,0 +1,144 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure.health; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.function.Supplier; + +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import org.jspecify.annotations.Nullable; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryUtils; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.annotation.BeanFactoryAnnotationUtils; +import org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthProperties.Service; +import org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthProperties.Status; +import org.springframework.boot.grpc.server.health.HealthCheckedGrpcComponent; +import org.springframework.boot.grpc.server.health.HealthCheckedGrpcComponents; +import org.springframework.boot.grpc.server.health.StatusAggregator; +import org.springframework.boot.grpc.server.health.StatusMapper; +import org.springframework.boot.health.autoconfigure.contributor.HealthContributorMembership; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.util.CollectionUtils; +import org.springframework.util.ObjectUtils; + +/** + * Auto-configured {@link HealthCheckedGrpcComponents}. + * + * @author Phillip Webb + */ +class AutoConfiguredHealthCheckedGrpcComponents implements HealthCheckedGrpcComponents { + + private final HealthCheckedGrpcComponent server; + + private final Map services; + + /** + * Create a new {@link AutoConfiguredHealthCheckedGrpcComponents} instance. + * @param applicationContext the application context used to check for override beans + * @param properties the grpc server health properties + */ + AutoConfiguredHealthCheckedGrpcComponents(ApplicationContext applicationContext, + GrpcServerHealthProperties properties) { + ListableBeanFactory beanFactory = (applicationContext instanceof ConfigurableApplicationContext configurableContext) + ? configurableContext.getBeanFactory() : applicationContext; + StatusAggregator statusAggregator = getNonQualifiedBean(beanFactory, StatusAggregator.class, + () -> StatusAggregator.of(properties.getStatus().getOrder())); + StatusMapper statusMapper = getNonQualifiedBean(beanFactory, StatusMapper.class, + () -> StatusMapper.of(properties.getStatus().getMapping())); + this.server = new AutoConfiguredHealthCheckedGrpcComponent(HealthContributorMembership.always(), + statusAggregator, statusMapper); + this.services = createServices(properties.getService(), beanFactory, statusAggregator, statusMapper); + } + + private Map createServices(Map serviceProperties, + BeanFactory beanFactory, StatusAggregator defaultStatusAggregator, StatusMapper defaultStatusMapper) { + Map services = new TreeMap<>(); + serviceProperties.forEach((serviceName, service) -> { + Status status = service.getStatus(); + StatusAggregator statusAggregator = getQualifiedBean(beanFactory, StatusAggregator.class, serviceName, + () -> createStatusAggregator(status.getOrder(), defaultStatusAggregator)); + StatusMapper statusMapper = getQualifiedBean(beanFactory, StatusMapper.class, serviceName, + () -> createStatusMapper(status.getMapping(), defaultStatusMapper)); + HealthContributorMembership membership = HealthContributorMembership.byIncludeExclude(service.getInclude(), + service.getExclude()); + services.put(serviceName, + new AutoConfiguredHealthCheckedGrpcComponent(membership, statusAggregator, statusMapper)); + }); + return Collections.unmodifiableMap(services); + } + + private StatusAggregator createStatusAggregator(List order, StatusAggregator defaultStatusAggregator) { + return (!CollectionUtils.isEmpty(order)) ? StatusAggregator.of(order) : defaultStatusAggregator; + } + + private StatusMapper createStatusMapper(Map mapping, StatusMapper defaultStatusMapper) { + return (!CollectionUtils.isEmpty(mapping)) ? StatusMapper.of(mapping) : defaultStatusMapper; + } + + private T getNonQualifiedBean(ListableBeanFactory beanFactory, Class type, Supplier fallback) { + List candidates = new ArrayList<>(); + for (String beanName : BeanFactoryUtils.beanNamesForTypeIncludingAncestors(beanFactory, type)) { + String[] aliases = beanFactory.getAliases(beanName); + if (!BeanFactoryAnnotationUtils.isQualifierMatch( + (qualifier) -> !qualifier.equals(beanName) && !ObjectUtils.containsElement(aliases, qualifier), + beanName, beanFactory)) { + candidates.add(beanName); + } + } + if (candidates.isEmpty()) { + return fallback.get(); + } + if (candidates.size() == 1) { + return beanFactory.getBean(candidates.get(0), type); + } + return beanFactory.getBean(type); + } + + private T getQualifiedBean(BeanFactory beanFactory, Class type, String qualifier, Supplier fallback) { + try { + return BeanFactoryAnnotationUtils.qualifiedBeanOfType(beanFactory, type, qualifier); + } + catch (NoSuchBeanDefinitionException ex) { + return fallback.get(); + } + } + + @Override + public @Nullable HealthCheckedGrpcComponent getServer() { + return this.server; + } + + @Override + public Set getServiceNames() { + return this.services.keySet(); + } + + @Override + public @Nullable HealthCheckedGrpcComponent getService(String serviceName) { + return this.services.get(serviceName); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfiguration.java new file mode 100644 index 00000000000..f525a6e77b6 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfiguration.java @@ -0,0 +1,142 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure.health; + +import io.grpc.BindableService; +import io.grpc.Grpc; +import io.grpc.protobuf.services.HealthStatusManager; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthAutoConfiguration.NotDisabledAndHasBindableServiceOrExplicitlyEnabledCondition; +import org.springframework.boot.grpc.server.health.GrpcServerHealth; +import org.springframework.boot.grpc.server.health.HealthCheckedGrpcComponents; +import org.springframework.boot.grpc.server.health.StatusAggregator; +import org.springframework.boot.grpc.server.health.StatusMapper; +import org.springframework.boot.health.autoconfigure.contributor.HealthContributorMembershipValidator; +import org.springframework.boot.health.registry.HealthContributorRegistry; +import org.springframework.boot.health.registry.ReactiveHealthContributorRegistry; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.GrpcServerFactory; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for gRPC server-side health service. + * + * @author Daniel Theuke + * @author Chris Bono + * @author Phillip Webb + * @since 4.1.0 + */ +@AutoConfiguration( + afterName = "org.springframework.boot.health.autoconfigure.registry.HealthContributorRegistryAutoConfiguration") +@ConditionalOnClass({ GrpcServerFactory.class, Grpc.class, HealthStatusManager.class }) +@ConditionalOnBooleanProperty(name = "spring.grpc.server.enabled", matchIfMissing = true) +@Conditional(NotDisabledAndHasBindableServiceOrExplicitlyEnabledCondition.class) +@EnableConfigurationProperties(GrpcServerHealthProperties.class) +public final class GrpcServerHealthAutoConfiguration { + + @Bean(destroyMethod = "enterTerminalState") + @ConditionalOnMissingBean + HealthStatusManager grpcServerHealthStatusManager() { + return new HealthStatusManager(); + } + + @Bean + BindableService grpcServerHealthService(HealthStatusManager healthStatusManager) { + return healthStatusManager.getHealthService(); + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnBean(type = "org.springframework.boot.health.registry.HealthContributorRegistry") + static class GrpcServerHealthContributorConfiguration { + + static final String VALIDATE_MEMBERSHIP_PROPERTY = "spring.grpc.server.health.services.validate-membership"; + + @Bean + @ConditionalOnMissingBean + StatusAggregator grpcServerHealthStatusAggregator(GrpcServerHealthProperties properties) { + return StatusAggregator.of(properties.getStatus().getOrder()); + } + + @Bean + @ConditionalOnMissingBean + StatusMapper grpcServerHealthHttpCodeStatusMapper(GrpcServerHealthProperties properties) { + return StatusMapper.of(properties.getStatus().getMapping()); + } + + @Bean + @ConditionalOnMissingBean(HealthCheckedGrpcComponents.class) + AutoConfiguredHealthCheckedGrpcComponents grpcServerHealthCheckedGrpcComponents( + ApplicationContext applicationContext, GrpcServerHealthProperties properties) { + return new AutoConfiguredHealthCheckedGrpcComponents(applicationContext, properties); + } + + @Bean + @ConditionalOnMissingBean + GrpcServerHealth grpcServerHealth(HealthContributorRegistry healthContributorRegistry, + ObjectProvider reactiveHealthContributorRegistry, + HealthCheckedGrpcComponents healthCheckedGrpcComponents) { + return new GrpcServerHealth(healthContributorRegistry, reactiveHealthContributorRegistry.getIfAvailable(), + healthCheckedGrpcComponents); + } + + @Bean + @ConditionalOnBooleanProperty(name = VALIDATE_MEMBERSHIP_PROPERTY, matchIfMissing = true) + HealthContributorMembershipValidator grpcServerHealthServiceMembershipValidator( + GrpcServerHealthProperties properties, HealthContributorRegistry healthContributorRegistry, + ObjectProvider reactiveHealthContributorRegistry) { + return new HealthContributorMembershipValidator(healthContributorRegistry, + reactiveHealthContributorRegistry.getIfAvailable(), VALIDATE_MEMBERSHIP_PROPERTY, + (members) -> properties.getService().forEach((serviceName, service) -> { + String property = "spring.grpc.server.health.service." + serviceName; + members.member(property + ".include".formatted(serviceName), service.getInclude()); + members.member(property + ".exclude".formatted(serviceName), service.getExclude()); + })); + } + + } + + static class NotDisabledAndHasBindableServiceOrExplicitlyEnabledCondition extends AnyNestedCondition { + + NotDisabledAndHasBindableServiceOrExplicitlyEnabledCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnBean(BindableService.class) + @ConditionalOnBooleanProperty(name = "spring.grpc.server.health.enabled", matchIfMissing = true) + static class NotDisabledAndHasBindableService { + + } + + @ConditionalOnBooleanProperty(name = "spring.grpc.server.health.enabled") + static class ExplicitlyEnabled { + + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthProperties.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthProperties.java new file mode 100644 index 00000000000..fc80154becc --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthProperties.java @@ -0,0 +1,248 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure.health; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; +import org.springframework.util.CollectionUtils; + +/** + * {@link ConfigurationProperties Properties} for Spring gRPC server health. + * + * @author Phillip Webb + * @author Chris Bono + * @since 4.1.0 + */ +@ConfigurationProperties("spring.grpc.server.health") +public class GrpcServerHealthProperties { + + /** + * Whether to auto-configure Health feature on the gRPC server. + */ + private @Nullable Boolean enabled; + + /** + * Whether to include the overall server health. + */ + private boolean includeOverallHealth = true; + + /** + * Properties that apply to all services. + */ + private final Services services = new Services(); + + /** + * Service specific health reporting. + */ + private final Map service = new LinkedHashMap<>(); + + /** + * Status configuration. + */ + private final Status status = new Status(); + + /** + * Schedule configuration. + */ + private final Schedule schedule = new Schedule(); + + public @Nullable Boolean getEnabled() { + return this.enabled; + } + + public void setEnabled(@Nullable Boolean enabled) { + this.enabled = enabled; + } + + public boolean isIncludeOverallHealth() { + return this.includeOverallHealth; + } + + public void setIncludeOverallHealth(boolean includeOverallHealth) { + this.includeOverallHealth = includeOverallHealth; + } + + public Services getServices() { + return this.services; + } + + public Map getService() { + return this.service; + } + + public Status getStatus() { + return this.status; + } + + public Schedule getSchedule() { + return this.schedule; + } + + /** + * Properties applying to all services. + */ + public static class Services { + + /** + * Whether to validate health membership for services on startup. Validation fails + * if a service includes or excludes a health contributor that does not exist. + */ + private boolean validateMembership = true; + + public boolean isValidateMembership() { + return this.validateMembership; + } + + public void setValidateMembership(boolean validateMembership) { + this.validateMembership = validateMembership; + } + + } + + /** + * A health for a specific service. + */ + public static class Service { + + /** + * Health indicator IDs that should be included or '*' for all. + */ + private @Nullable Set include; + + /** + * Health indicator IDs that should be excluded or '*' for all. + */ + private @Nullable Set exclude; + + /** + * Status configuration. + */ + @NestedConfigurationProperty + private final Status status = new Status(); + + public Status getStatus() { + return this.status; + } + + public @Nullable Set getInclude() { + return this.include; + } + + public void setInclude(@Nullable Set include) { + this.include = include; + } + + public @Nullable Set getExclude() { + return this.exclude; + } + + public void setExclude(@Nullable Set exclude) { + this.exclude = exclude; + } + + } + + /** + * Status properties for the group. + */ + public static class Status { + + /** + * List of health statuses in order of severity. + */ + private List order = new ArrayList<>(); + + /** + * Mapping of health statuses to gRPC service status. By default, registered + * health statuses map to sensible defaults (for example, UP maps to SERVING). + */ + private final Map mapping = new HashMap<>(); + + public List getOrder() { + return this.order; + } + + public void setOrder(List statusOrder) { + if (!CollectionUtils.isEmpty(statusOrder)) { + this.order = statusOrder; + } + } + + public Map getMapping() { + return this.mapping; + } + + } + + /** + * Health task scheduling. + */ + public static class Schedule { + + /** + * Whether to schedule updates to gRPC server health based on application health. + */ + private boolean enabled = true; + + /** + * How often to update the health status. + */ + private Duration period = Duration.ofSeconds(5); + + /** + * The initial delay before updating the health status the very first time. + */ + private Duration delay = Duration.ofSeconds(5); + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Duration getPeriod() { + return this.period; + } + + public void setPeriod(Duration period) { + this.period = period; + } + + public Duration getDelay() { + return this.delay; + } + + public void setDelay(Duration delay) { + this.delay = delay; + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthScheduler.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthScheduler.java new file mode 100644 index 00000000000..3bf7cb8a0af --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthScheduler.java @@ -0,0 +1,69 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure.health; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ScheduledFuture; + +import io.grpc.protobuf.services.HealthStatusManager; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.boot.grpc.server.health.GrpcServerHealth; +import org.springframework.context.ApplicationListener; +import org.springframework.core.log.LogMessage; +import org.springframework.grpc.server.lifecycle.GrpcServerStartedEvent; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.util.function.SingletonSupplier; + +/** + * Schedules gRPC health updates one the gRPC server has been started. + * + * @author Phillip Webb + * @author Chris Bono + */ +class GrpcServerHealthScheduler implements ApplicationListener { + + private static final Log logger = LogFactory.getLog(GrpcServerHealthScheduler.class); + + private final SingletonSupplier> scheduleHealth; + + GrpcServerHealthScheduler(GrpcServerHealth grpcServerHealth, HealthStatusManager grpcServerHealthStatusManager, + TaskScheduler taskScheduler, Duration period, Duration delay) { + this(Clock.systemDefaultZone(), grpcServerHealth, grpcServerHealthStatusManager, taskScheduler, period, delay); + } + + GrpcServerHealthScheduler(Clock clock, GrpcServerHealth grpcServerHealth, + HealthStatusManager grpcServerHealthStatusManager, TaskScheduler taskScheduler, Duration period, + Duration delay) { + this.scheduleHealth = SingletonSupplier.of(() -> { + logger.debug(LogMessage + .of(() -> "Scheduling gRPC server health updates every %s seconds (after a delay of %s seconds)" + .formatted((period.toMillis() / 1000.0), delay.toMillis() / 1000.0))); + Runnable task = () -> grpcServerHealth.update(grpcServerHealthStatusManager); + return taskScheduler.scheduleAtFixedRate(task, Instant.now(clock).plus(delay), period); + }); + } + + @Override + public void onApplicationEvent(GrpcServerStartedEvent event) { + this.scheduleHealth.get(); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthSchedulerAutoConfiguration.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthSchedulerAutoConfiguration.java new file mode 100644 index 00000000000..972d4a79d3f --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthSchedulerAutoConfiguration.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure.health; + +import io.grpc.Grpc; +import io.grpc.protobuf.services.HealthStatusManager; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.task.DefaultTaskSchedulerConfiguration; +import org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthProperties.Schedule; +import org.springframework.boot.grpc.server.health.GrpcServerHealth; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.grpc.server.GrpcServerFactory; +import org.springframework.scheduling.TaskScheduler; + +/** + * {@link EnableAutoConfiguration Auto-configuration} to invoke {@link GrpcServerHealth} + * updates using a {@link TaskScheduler}. + * + * @author Phillip Webb + * @since 4.1.0 + */ +@AutoConfiguration(after = GrpcServerHealthAutoConfiguration.class) +@ConditionalOnBooleanProperty(name = "spring.grpc.server.health.schedule.enabled", matchIfMissing = true) +@Import(DefaultTaskSchedulerConfiguration.class) +@ConditionalOnClass({ GrpcServerFactory.class, Grpc.class, HealthStatusManager.class }) +public final class GrpcServerHealthSchedulerAutoConfiguration { + + @Bean + @ConditionalOnBean({ TaskScheduler.class, GrpcServerHealth.class }) + GrpcServerHealthScheduler grpcServerHealthScheduler(GrpcServerHealth grpcServerHealth, + HealthStatusManager grpcServerHealthStatusManager, TaskScheduler taskScheduler, + GrpcServerHealthProperties properties) { + Schedule schedule = properties.getSchedule(); + return new GrpcServerHealthScheduler(grpcServerHealth, grpcServerHealthStatusManager, taskScheduler, + schedule.getPeriod(), schedule.getDelay()); + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/package-info.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/package-info.java new file mode 100644 index 00000000000..764a25d911e --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/autoconfigure/health/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2012-present 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. + */ + +/** + * Auto-configuration for gRPC server health checks. + */ +@NullMarked +package org.springframework.boot.grpc.server.autoconfigure.health; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/GrpcServerHealth.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/GrpcServerHealth.java new file mode 100644 index 00000000000..0ff80ca4cc6 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/GrpcServerHealth.java @@ -0,0 +1,122 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.health; + +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; + +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import io.grpc.protobuf.services.HealthStatusManager; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.health.contributor.CompositeHealthContributor; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthContributors; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.health.contributor.Status; +import org.springframework.boot.health.registry.HealthContributorRegistry; +import org.springframework.boot.health.registry.ReactiveHealthContributorRegistry; +import org.springframework.util.Assert; + +/** + * Provides health information for a gRPC server that can be used to update a + * {@link HealthStatusManager}. + * + * @author Phillip Webb + * @since 4.1.0 + */ +public class GrpcServerHealth { + + private HealthContributorRegistry registry; + + private @Nullable ReactiveHealthContributorRegistry fallbackRegistry; + + private HealthCheckedGrpcComponents components; + + /** + * Create a new {@link GrpcServerHealth} instance. + * @param registry the health contributor registry + * @param fallbackRegistry the fallback registry or {@code null} + * @param components the components used to provide the server health + */ + public GrpcServerHealth(HealthContributorRegistry registry, + @Nullable ReactiveHealthContributorRegistry fallbackRegistry, HealthCheckedGrpcComponents components) { + Assert.notNull(registry, "'registry' must not be null"); + Assert.notNull(components, "'components' must not be null"); + this.registry = registry; + this.fallbackRegistry = fallbackRegistry; + this.components = components; + } + + public void update(HealthStatusManager manager) { + update(manager::setStatus); + } + + public void update(BiConsumer updator) { + Cache cache = new Cache(); + HealthCheckedGrpcComponent serverComponent = this.components.getServer(); + if (serverComponent != null) { + updator.accept("", getServingStatus(cache, serverComponent)); + } + for (String serviceName : this.components.getServiceNames()) { + HealthCheckedGrpcComponent serviceComponent = this.components.getService(serviceName); + if (!serviceName.isEmpty() && serviceComponent != null) { + updator.accept(serviceName, getServingStatus(cache, serviceComponent)); + } + } + } + + private ServingStatus getServingStatus(Cache cache, HealthCheckedGrpcComponent component) { + Set statuses = new LinkedHashSet<>(); + collectStatuses(cache, component, statuses, this.registry, ""); + if (this.fallbackRegistry != null) { + collectStatuses(cache, component, statuses, this.fallbackRegistry.asHealthContributors(), ""); + } + Status status = component.getStatusAggregator().getAggregateStatus(statuses); + return component.getStatusMapper().getServingStatus(status); + } + + private void collectStatuses(Cache cache, HealthCheckedGrpcComponent component, Set statuses, + HealthContributors contributors, String prefix) { + for (HealthContributors.Entry entry : contributors) { + String name = (prefix.isEmpty()) ? entry.name() : prefix + "/" + entry.name(); + if (entry.contributor() instanceof CompositeHealthContributor composite) { + collectStatuses(cache, component, statuses, composite, name); + } + else if (component.isMember(name)) { + Health health = cache.getHealth(name, (HealthIndicator) entry.contributor()); + if (health != null) { + statuses.add(health.getStatus()); + } + } + } + } + + class Cache { + + private final Map health = new HashMap<>(); + + Health getHealth(String name, HealthIndicator indicator) { + return this.health.computeIfAbsent(name, (key) -> indicator.health(false)); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/HealthCheckedGrpcComponent.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/HealthCheckedGrpcComponent.java new file mode 100644 index 00000000000..fd71d19007e --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/HealthCheckedGrpcComponent.java @@ -0,0 +1,46 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.health; + +/** + * A single gRPC component that can be checked for health. + * + * @author Phillip Webb + * @since 4.1.0 + */ +public interface HealthCheckedGrpcComponent { + + /** + * Returns {@code true} if the given health contributor is a member of this component. + * @param healthContributorName the contributor name + * @return {@code true} if the contributor is a member + */ + boolean isMember(String healthContributorName); + + /** + * Returns the status aggregator that should be used for this component. + * @return the status aggregator + */ + StatusAggregator getStatusAggregator(); + + /** + * Returns the {@link StatusMapper} that should be used for this component. + * @return the status mapper + */ + StatusMapper getStatusMapper(); + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/HealthCheckedGrpcComponents.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/HealthCheckedGrpcComponents.java new file mode 100644 index 00000000000..5673c6bbf66 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/HealthCheckedGrpcComponents.java @@ -0,0 +1,54 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.health; + +import java.util.Set; + +import org.jspecify.annotations.Nullable; + +/** + * A collection of {@link HealthCheckedGrpcComponent components} used to check the health + * of a gRPC server. + * + * @author Phillip Webb + * @since 4.1.0 + * @see GrpcServerHealth + */ +public interface HealthCheckedGrpcComponents { + + /** + * Return the component that represents the overall server health or {@code null} if + * no overall health should be reported. + * @return the server component or {@code null} + */ + @Nullable HealthCheckedGrpcComponent getServer(); + + /** + * Return the names of the services that contribute health checks. + * @return the service names + */ + Set getServiceNames(); + + /** + * Return the component for the service with the specified name or {@code null} if the + * name is not known. + * @param serviceName the name of the service + * @return the service component or {@code null} + */ + @Nullable HealthCheckedGrpcComponent getService(String serviceName); + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/SimpleServingStatusMapper.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/SimpleServingStatusMapper.java new file mode 100644 index 00000000000..769c4b0cce2 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/SimpleServingStatusMapper.java @@ -0,0 +1,79 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.health; + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.health.contributor.Status; +import org.springframework.lang.Contract; + +/** + * Simple {@link StatusMapper} backed by map of {@link Status#getCode() status code} to + * {@link ServingStatus}. + * + * @author Phillip Webb + */ +class SimpleServingStatusMapper implements StatusMapper { + + static final SimpleServingStatusMapper DEFAULT_MAPPINGS; + static { + Map mappings = new HashMap<>(); + mappings.put(Status.DOWN.getCode(), ServingStatus.NOT_SERVING); + mappings.put(Status.OUT_OF_SERVICE.getCode(), ServingStatus.NOT_SERVING); + mappings.put(Status.UNKNOWN.getCode(), ServingStatus.UNKNOWN); + DEFAULT_MAPPINGS = new SimpleServingStatusMapper(mappings); + } + + private final Map mappings; + + SimpleServingStatusMapper(Map mappings) { + this.mappings = getUniformMappings(mappings); + } + + @Override + public ServingStatus getServingStatus(Status status) { + String code = getUniformCode(status.getCode()); + return this.mappings.getOrDefault(code, ServingStatus.SERVING); + } + + private static Map getUniformMappings(Map mappings) { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : mappings.entrySet()) { + String code = getUniformCode(entry.getKey()); + if (code != null) { + result.putIfAbsent(code, entry.getValue()); + } + } + return Collections.unmodifiableMap(result); + } + + @Contract("!null -> !null") + private static @Nullable String getUniformCode(@Nullable String code) { + return (code != null) ? code.codePoints() + .filter(Character::isLetterOrDigit) + .map(Character::toLowerCase) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString() : null; + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/SimpleStatusAggregator.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/SimpleStatusAggregator.java new file mode 100644 index 00000000000..1c4f57a038e --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/SimpleStatusAggregator.java @@ -0,0 +1,74 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.health; + +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.health.contributor.Status; +import org.springframework.lang.Contract; + +/** + * {@link StatusAggregator} backed by an ordered status list. + * + * @author Phillip Webb + */ +class SimpleStatusAggregator implements StatusAggregator { + + static final SimpleStatusAggregator DEFAULT_ORDER = new SimpleStatusAggregator( + Status.DEFAULT_ORDER.stream().map(Status::getCode)); + + private final List order; + + private final Comparator comparator = Comparator.comparingInt(this::orderIndex) + .thenComparing(Status::getCode); + + SimpleStatusAggregator(Stream order) { + this.order = order.map(SimpleStatusAggregator::getUniformCode).toList(); + } + + @Override + public Status getAggregateStatus(Set statuses) { + return statuses.stream().filter(this::contains).min(this.comparator).orElse(Status.UNKNOWN); + } + + private boolean contains(Status status) { + return this.order.contains(getUniformCode(status)); + } + + private int orderIndex(Status status) { + return this.order.indexOf(getUniformCode(status)); + } + + private static @Nullable String getUniformCode(Status status) { + return getUniformCode(status.getCode()); + } + + @Contract("!null -> !null") + private static @Nullable String getUniformCode(@Nullable String code) { + return (code != null) ? code.codePoints() + .filter(Character::isLetterOrDigit) + .map(Character::toLowerCase) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString() : null; + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/StatusAggregator.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/StatusAggregator.java new file mode 100644 index 00000000000..f8fe4e47b61 --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/StatusAggregator.java @@ -0,0 +1,95 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.health; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.Status; +import org.springframework.util.ObjectUtils; + +/** + * Strategy used to aggregate {@link Status} instances. + *

+ * This is required in order to combine subsystem states expressed through + * {@link Health#getStatus()} into one state for the gRPC health. + * + * @author Phillip Webb + * @since 4.1.0 + */ +@FunctionalInterface +public interface StatusAggregator { + + /** + * Return the aggregate status for the given set of statuses. + * @param statuses the statuses to aggregate + * @return the aggregate status + */ + default Status getAggregateStatus(Status... statuses) { + return getAggregateStatus(new LinkedHashSet<>(Arrays.asList(statuses))); + } + + /** + * Return the aggregate status for the given set of statuses. + * @param statuses the statuses to aggregate + * @return the aggregate status + */ + Status getAggregateStatus(Set statuses); + + /** + * Return a {@link StatusAggregator} backed by the given ordered status list. + * @param order the status order + * @return a {@link StatusAggregator} instance or {@link #getDefault()} if no order is + * provided + */ + static StatusAggregator of(Status... order) { + return (!ObjectUtils.isEmpty(order)) ? new SimpleStatusAggregator(Arrays.stream(order).map(Status::getCode)) + : getDefault(); + } + + /** + * Return a {@link StatusAggregator} backed by the given ordered status list. + * @param order the status order + * @return a {@link StatusAggregator} instance or {@link #getDefault()} if no order is + * provided + */ + static StatusAggregator of(String... order) { + return (!ObjectUtils.isEmpty(order)) ? new SimpleStatusAggregator(Arrays.stream(order)) : getDefault(); + } + + /** + * Return a {@link StatusAggregator} backed by the given ordered status list. + * @param order the status order + * @return a {@link StatusAggregator} instance or {@link #getDefault()} if no order is + * provided + */ + static StatusAggregator of(List order) { + return (!ObjectUtils.isEmpty(order)) ? new SimpleStatusAggregator(order.stream()) : getDefault(); + } + + /** + * Return {@link StatusAggregator} instance using default ordering rules. + * @return a {@code StatusAggregator} with default ordering rules. + */ + static StatusAggregator getDefault() { + return SimpleStatusAggregator.DEFAULT_ORDER; + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/StatusMapper.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/StatusMapper.java new file mode 100644 index 00000000000..837a1be208d --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/StatusMapper.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.health; + +import java.util.Map; + +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.health.contributor.Status; +import org.springframework.util.CollectionUtils; + +/** + * Strategy used to map a {@link Status health status} to a gRPC {@link ServingStatus}. + * + * @author Phillip Webb + * @since 4.1.0 + */ +@FunctionalInterface +public interface StatusMapper { + + /** + * Return the HTTP status code that corresponds to the given {@link Status health + * status}. + * @param status the health status to map + * @return the corresponding HTTP status code + */ + ServingStatus getServingStatus(Status status); + + /** + * Create a new {@link StatusMapper} with the specified mappings. + * @param mappings the mappings to use or {@code null} to use the default mappings + * @return a {@link StatusMapper} or {@link #getDefault()} + */ + static StatusMapper of(@Nullable Map mappings) { + return CollectionUtils.isEmpty(mappings) ? SimpleServingStatusMapper.DEFAULT_MAPPINGS + : new SimpleServingStatusMapper(mappings); + } + + /** + * Return an {@link StatusMapper} instance using default mappings. + * @return a mapper using default mappings + */ + static StatusMapper getDefault() { + return SimpleServingStatusMapper.DEFAULT_MAPPINGS; + } + +} diff --git a/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/package-info.java b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/package-info.java new file mode 100644 index 00000000000..cd575a4c15a --- /dev/null +++ b/module/spring-boot-grpc-server/src/main/java/org/springframework/boot/grpc/server/health/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright 2012-present 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. + */ + +/** + * Support for gRPC server health checks. + */ +@NullMarked +package org.springframework.boot.grpc.server.health; + +import org.jspecify.annotations.NullMarked; diff --git a/module/spring-boot-grpc-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/module/spring-boot-grpc-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index b5ab2390842..c060e23ab8d 100644 --- a/module/spring-boot-grpc-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/module/spring-boot-grpc-server/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,2 +1,4 @@ org.springframework.boot.grpc.server.autoconfigure.GrpcServerAutoConfiguration org.springframework.boot.grpc.server.autoconfigure.GrpcServerServicesAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthAutoConfiguration +org.springframework.boot.grpc.server.autoconfigure.health.GrpcServerHealthSchedulerAutoConfiguration \ No newline at end of file diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponentTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponentTests.java new file mode 100644 index 00000000000..13e14d76476 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponentTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure.health; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.boot.grpc.server.health.StatusAggregator; +import org.springframework.boot.grpc.server.health.StatusMapper; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link AutoConfiguredHealthCheckedGrpcComponent}. + * + * @author Phillip Webb + */ +@ExtendWith(MockitoExtension.class) +class AutoConfiguredHealthCheckedGrpcComponentTests { + + @Mock + @SuppressWarnings("NullAway.Init") + private StatusAggregator statusAggregator; + + @Mock + @SuppressWarnings("NullAway.Init") + private StatusMapper statusMapper; + + @Test + void isMemberWhenMembershipMatchesAcceptsTrue() { + AutoConfiguredHealthCheckedGrpcComponent component = new AutoConfiguredHealthCheckedGrpcComponent( + (name) -> name.startsWith("a"), this.statusAggregator, this.statusMapper); + assertThat(component.isMember("albert")).isTrue(); + assertThat(component.isMember("arnold")).isTrue(); + } + + @Test + void isMemberWhenMembershipRejectsReturnsTrue() { + AutoConfiguredHealthCheckedGrpcComponent component = new AutoConfiguredHealthCheckedGrpcComponent( + (name) -> name.startsWith("a"), this.statusAggregator, this.statusMapper); + assertThat(component.isMember("bert")).isFalse(); + assertThat(component.isMember("ernie")).isFalse(); + } + + @Test + void getStatusAggregatorReturnsStatusAggregator() { + AutoConfiguredHealthCheckedGrpcComponent component = new AutoConfiguredHealthCheckedGrpcComponent( + (name) -> true, this.statusAggregator, this.statusMapper); + assertThat(component.getStatusAggregator()).isSameAs(this.statusAggregator); + } + + @Test + void getStatusMapperReturnsHttpCodeStatusMapper() { + AutoConfiguredHealthCheckedGrpcComponent component = new AutoConfiguredHealthCheckedGrpcComponent( + (name) -> true, this.statusAggregator, this.statusMapper); + assertThat(component.getStatusMapper()).isSameAs(this.statusMapper); + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponentsTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponentsTests.java new file mode 100644 index 00000000000..e0ece6931d1 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/AutoConfiguredHealthCheckedGrpcComponentsTests.java @@ -0,0 +1,437 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure.health; + +import java.util.Collections; + +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.grpc.server.health.HealthCheckedGrpcComponent; +import org.springframework.boot.grpc.server.health.HealthCheckedGrpcComponents; +import org.springframework.boot.grpc.server.health.StatusAggregator; +import org.springframework.boot.grpc.server.health.StatusMapper; +import org.springframework.boot.health.contributor.Status; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link AutoConfiguredHealthCheckedGrpcComponent}. + * + * @author Phillip Webb + */ +class AutoConfiguredHealthCheckedGrpcComponentsTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(AutoConfiguredHealthCheckedGrpcComponentsTestConfiguration.class)); + + @Test + void getServerMatchesAllMembers() { + this.contextRunner.run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + assertThat(server).isNotNull(); + assertThat(server.isMember("a")).isTrue(); + assertThat(server.isMember("b")).isTrue(); + assertThat(server.isMember("C")).isTrue(); + }); + } + + @Test + void getServiceNamesReturnsServiceNames() { + this.contextRunner + .withPropertyValues("spring.grpc.server.health.service.a.include=*", + "spring.grpc.server.health.service.b.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + assertThat(components.getServiceNames()).containsExactlyInAnyOrder("a", "b"); + }); + } + + @Test + void getServiceWhenServiceExistsReturnsService() { + this.contextRunner.withPropertyValues("spring.grpc.server.health.service.a.include=*").run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent component = components.getService("a"); + assertThat(component).isNotNull(); + }); + } + + @Test + void getServiceWhenServiceDoesNotExistReturnsNull() { + this.contextRunner.withPropertyValues("spring.grpc.server.health.service.a.include=*").run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent component = components.getService("b"); + assertThat(component).isNull(); + }); + } + + @Test + void createWhenNoDefinedBeansAdaptsProperties() { + this.contextRunner + .withPropertyValues("spring.grpc.server.health.status.order=up,down", + "spring.grpc.server.health.status.mapping.down=serving") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + assertThat(server).isNotNull(); + assertThat(server.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN)) + .isEqualTo(Status.UP); + assertThat(server.getStatusMapper().getServingStatus(Status.DOWN)).isEqualTo(ServingStatus.SERVING); + }); + } + + @Test + void createWhenHasStatusAggregatorBeanReturnsInstanceWithAggregatorUsedForAllServices() { + this.contextRunner.withUserConfiguration(CustomStatusAggregatorConfiguration.class) + .withPropertyValues("spring.grpc.server.health.status.order=up,down", + "spring.grpc.server.health.service.a.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(server.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UNKNOWN); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UNKNOWN); + }); + } + + @Test + void createWhenHasStatusAggregatorBeanAndServiceSpecificPropertyReturnsInstanceThatUsesBeanOnlyForUnconfiguredServices() { + this.contextRunner.withUserConfiguration(CustomStatusAggregatorConfiguration.class) + .withPropertyValues("spring.grpc.server.health.service.a.include=*", + "spring.grpc.server.health.service.a.status.order=up,down", + "spring.grpc.server.health.service.b.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + HealthCheckedGrpcComponent serviceB = components.getService("b"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(serviceB).isNotNull(); + assertThat(server.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UNKNOWN); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UP); + assertThat(serviceB).isNotNull(); + assertThat(serviceB.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UNKNOWN); + }); + } + + @Test + void createWhenHasStatusAggregatorPropertyReturnsInstanceWithPropertyUsedForAllServices() { + this.contextRunner + .withPropertyValues("spring.grpc.server.health.status.order=up,down", + "spring.grpc.server.health.service.a.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(server.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN)) + .isEqualTo(Status.UP); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN)) + .isEqualTo(Status.UP); + }); + } + + @Test + void createWhenHasStatusAggregatorPropertyAndServiceSpecificPropertyReturnsInstanceWithPropertyUsedForExpectedServices() { + this.contextRunner + .withPropertyValues("spring.grpc.server.health.status.order=up,down", + "spring.grpc.server.health.service.a.include=*", + "spring.grpc.server.health.service.a.status.order=unknown,up,down", + "spring.grpc.server.health.service.b.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + HealthCheckedGrpcComponent serviceB = components.getService("b"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(serviceB).isNotNull(); + assertThat(server.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UP); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UNKNOWN); + assertThat(serviceB).isNotNull(); + assertThat(serviceB.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UP); + }); + } + + @Test + void createWhenHasStatusAggregatorPropertyAndServiceQualifiedBeanReturnsInstanceWithBeanUsedForExpectedServices() { + this.contextRunner.withUserConfiguration(CustomStatusAggregatorServiceAConfiguration.class) + .withPropertyValues("spring.grpc.server.health.status.order=up,down", + "spring.grpc.server.health.service.a.include=*", + "spring.grpc.server.health.service.a.status.order=up,down", + "spring.grpc.server.health.service.b.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + HealthCheckedGrpcComponent serviceB = components.getService("b"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(serviceB).isNotNull(); + assertThat(server.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UP); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UNKNOWN); + assertThat(serviceB).isNotNull(); + assertThat(serviceB.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UP); + }); + } + + @Test + void createWhenHasServiceSpecificStatusAggregatorPropertyAndServiceQualifiedBeanReturnsInstanceWithBeanUsedForExpectedServices() { + this.contextRunner.withUserConfiguration(CustomStatusAggregatorServiceAConfiguration.class) + .withPropertyValues("spring.grpc.server.health.service.a.include=*", + "spring.grpc.server.health.service.a.status.order=up,down", + "spring.grpc.server.health.service.b.include=*", + "spring.grpc.server.health.service.b.status.order=up,down") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + HealthCheckedGrpcComponent serviceB = components.getService("b"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(serviceB).isNotNull(); + assertThat(server.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.DOWN); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UNKNOWN); + assertThat(serviceB).isNotNull(); + assertThat(serviceB.getStatusAggregator().getAggregateStatus(Status.UP, Status.DOWN, Status.UNKNOWN)) + .isEqualTo(Status.UP); + }); + } + + @Test + void createWhenHasStatusMapperBeanReturnsInstanceWithMapperUsedForAllServices() { + this.contextRunner.withUserConfiguration(CustomStatusMapperConfiguration.class) + .withPropertyValues("spring.grpc.server.health.status.mapping.down=service-unknown", + "spring.grpc.server.health.service.a.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(server.getStatusMapper().getServingStatus(Status.DOWN)).isEqualTo(ServingStatus.SERVING); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusMapper().getServingStatus(Status.DOWN)).isEqualTo(ServingStatus.SERVING); + }); + } + + @Test + void createWhenHasStatusMapperBeanAndServiceSpecificPropertyReturnsInstanceThatUsesBeanOnlyForUnconfiguredServices() { + this.contextRunner.withUserConfiguration(CustomStatusMapperConfiguration.class) + .withPropertyValues("spring.grpc.server.health.service.a.include=*", + "spring.grpc.server.health.service.a.status.mapping.down=service-unknown", + "spring.grpc.server.health.service.b.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + HealthCheckedGrpcComponent serviceB = components.getService("b"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(serviceB).isNotNull(); + assertThat(server.getStatusMapper().getServingStatus(Status.DOWN)).isEqualTo(ServingStatus.SERVING); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusMapper().getServingStatus(Status.DOWN)) + .isEqualTo(ServingStatus.SERVICE_UNKNOWN); + assertThat(serviceB).isNotNull(); + assertThat(serviceB.getStatusMapper().getServingStatus(Status.DOWN)).isEqualTo(ServingStatus.SERVING); + }); + } + + @Test + void createWhenHasStatusMapperPropertyReturnsInstanceWithPropertyUsedForAllServices() { + this.contextRunner + .withPropertyValues("spring.grpc.server.health.status.mapping.down=service-unknown", + "spring.grpc.server.health.service.a.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(server.getStatusMapper().getServingStatus(Status.DOWN)) + .isEqualTo(ServingStatus.SERVICE_UNKNOWN); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusMapper().getServingStatus(Status.DOWN)) + .isEqualTo(ServingStatus.SERVICE_UNKNOWN); + }); + } + + @Test + void createWhenHasStatusMapperPropertyAndServiceSpecificPropertyReturnsInstanceWithPropertyUsedForExpectedServices() { + this.contextRunner + .withPropertyValues("spring.grpc.server.health.status.mapping.down=service-unknown", + "spring.grpc.server.health.service.a.include=*", + "spring.grpc.server.health.service.a.status.mapping.down=unrecognized", + "spring.grpc.server.health.service.b.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + HealthCheckedGrpcComponent serviceB = components.getService("b"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(serviceB).isNotNull(); + assertThat(server.getStatusMapper().getServingStatus(Status.DOWN)) + .isEqualTo(ServingStatus.SERVICE_UNKNOWN); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusMapper().getServingStatus(Status.DOWN)) + .isEqualTo(ServingStatus.UNRECOGNIZED); + assertThat(serviceB).isNotNull(); + assertThat(serviceB.getStatusMapper().getServingStatus(Status.DOWN)) + .isEqualTo(ServingStatus.SERVICE_UNKNOWN); + }); + } + + @Test + void createWhenHasStatusMapperPropertyAndServiceQualifiedBeanReturnsInstanceWithBeanUsedForExpectedServices() { + this.contextRunner.withUserConfiguration(CustomStatusMapperServiceAConfiguration.class) + .withPropertyValues("spring.grpc.server.health.status.mapping.down=service-unknown", + "spring.grpc.server.health.service.a.include=*", + "spring.grpc.server.health.service.a.status.mapping.down=service-unknown", + "spring.grpc.server.health.service.b.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + HealthCheckedGrpcComponent serviceB = components.getService("b"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(serviceB).isNotNull(); + assertThat(server.getStatusMapper().getServingStatus(Status.DOWN)) + .isEqualTo(ServingStatus.SERVICE_UNKNOWN); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusMapper().getServingStatus(Status.DOWN)).isEqualTo(ServingStatus.SERVING); + assertThat(serviceB).isNotNull(); + assertThat(serviceB.getStatusMapper().getServingStatus(Status.DOWN)) + .isEqualTo(ServingStatus.SERVICE_UNKNOWN); + }); + } + + @Test + void createWhenHasServiceSpecificStatusMapperPropertyAndServiceQualifiedBeanReturnsInstanceWithBeanUsedForExpectedServices() { + this.contextRunner.withUserConfiguration(CustomStatusMapperServiceAConfiguration.class) + .withPropertyValues("spring.grpc.server.health.service.a.include=*", + "spring.grpc.server.health.service.a.status.mapping.down=service-unknown", + "spring.grpc.server.health.service.b.include=*", + "spring.grpc.server.health.service.b.status.mapping.down=service-unknown") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + HealthCheckedGrpcComponent server = components.getServer(); + HealthCheckedGrpcComponent serviceA = components.getService("a"); + HealthCheckedGrpcComponent serviceB = components.getService("b"); + assertThat(server).isNotNull(); + assertThat(serviceA).isNotNull(); + assertThat(serviceB).isNotNull(); + assertThat(server.getStatusMapper().getServingStatus(Status.DOWN)).isEqualTo(ServingStatus.NOT_SERVING); + assertThat(serviceA).isNotNull(); + assertThat(serviceA.getStatusMapper().getServingStatus(Status.DOWN)).isEqualTo(ServingStatus.SERVING); + assertThat(serviceB).isNotNull(); + assertThat(serviceB.getStatusMapper().getServingStatus(Status.DOWN)) + .isEqualTo(ServingStatus.SERVICE_UNKNOWN); + }); + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(GrpcServerHealthProperties.class) + static class AutoConfiguredHealthCheckedGrpcComponentsTestConfiguration { + + @Bean + AutoConfiguredHealthCheckedGrpcComponents healthCheckedGrpcComponents( + ConfigurableApplicationContext applicationContext, GrpcServerHealthProperties properties) { + return new AutoConfiguredHealthCheckedGrpcComponents(applicationContext, properties); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomStatusAggregatorConfiguration { + + @Bean + @Primary + StatusAggregator statusAggregator() { + return StatusAggregator.of(Status.UNKNOWN, Status.UP, Status.DOWN); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomStatusAggregatorServiceAConfiguration { + + @Bean + @Qualifier("a") + StatusAggregator statusAggregator() { + return StatusAggregator.of(Status.UNKNOWN, Status.UP, Status.DOWN); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomStatusMapperConfiguration { + + @Bean + @Primary + StatusMapper httpCodeStatusMapper() { + return StatusMapper.of(Collections.singletonMap(Status.DOWN.getCode(), ServingStatus.SERVING)); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomStatusMapperServiceAConfiguration { + + @Bean + @Qualifier("a") + StatusMapper httpCodeStatusMapper() { + return StatusMapper.of(Collections.singletonMap(Status.DOWN.getCode(), ServingStatus.SERVING)); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfigurationTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfigurationTests.java new file mode 100644 index 00000000000..ad9a30bbeeb --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthAutoConfigurationTests.java @@ -0,0 +1,458 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure.health; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import io.grpc.BindableService; +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import io.grpc.protobuf.services.HealthStatusManager; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.task.TaskSchedulingAutoConfiguration; +import org.springframework.boot.diagnostics.FailureAnalyzedException; +import org.springframework.boot.grpc.server.health.GrpcServerHealth; +import org.springframework.boot.grpc.server.health.HealthCheckedGrpcComponents; +import org.springframework.boot.grpc.server.health.StatusAggregator; +import org.springframework.boot.grpc.server.health.StatusMapper; +import org.springframework.boot.health.autoconfigure.contributor.HealthContributorMembershipValidator; +import org.springframework.boot.health.autoconfigure.registry.HealthContributorRegistryAutoConfiguration; +import org.springframework.boot.health.contributor.CompositeHealthContributor; +import org.springframework.boot.health.contributor.CompositeReactiveHealthContributor; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.health.contributor.ReactiveHealthIndicator; +import org.springframework.boot.health.contributor.Status; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.assertj.ApplicationContextAssertProvider; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; +import org.springframework.boot.test.context.runner.AbstractApplicationContextRunner; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.grpc.server.GrpcServerFactory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GrpcServerHealthAutoConfiguration}. + * + * @author Phillip Webb + * @author Chris Bono + * @author Andrey Litvitski + */ +class GrpcServerHealthAutoConfigurationTests { + + private static final AutoConfigurations autoConfigurations = AutoConfigurations.of( + GrpcServerHealthAutoConfiguration.class, GrpcServerHealthSchedulerAutoConfiguration.class, + HealthContributorRegistryAutoConfiguration.class, TaskSchedulingAutoConfiguration.class); + + private final BindableService service = mock(); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(HealthIndicatorsConfiguration.class) + .withConfiguration(autoConfigurations) + .with(this::serviceBean); + + @Test + void autoConfiguresBeans() { + this.contextRunner.run(this::assertConfigured); + } + + @Test + void whenNoBindableServiceDefinedDoesNotAutoConfigureBeans() { + new ApplicationContextRunner().withConfiguration(autoConfigurations).run(this::assertNotConfigured); + } + + @Test + void whenNoBindableServiceDefinedButHealthEnabledPropertyIsTrueAutoConfiguresBeans() { + new ApplicationContextRunner().withConfiguration(autoConfigurations) + .withPropertyValues("spring.grpc.server.health.enabled=true") + .run(this::assertConfigured); + } + + @Test + void whenGrpcNotOnClasspathDoesNotAutoConfigureBeans() { + this.contextRunner.withClassLoader(new FilteredClassLoader(BindableService.class)) + .run(this::assertNotConfigured); + } + + @Test + void whenSpringGrpcNotOnClasspathDoesNotAutoConfigureBeans() { + this.contextRunner.withClassLoader(new FilteredClassLoader(GrpcServerFactory.class)) + .run(this::assertNotConfigured); + } + + @Test + void whenHealthStatusManagerNotOnClasspathDoesNotAutoConfigureBeans() { + this.contextRunner.withClassLoader(new FilteredClassLoader(HealthStatusManager.class)) + .run(this::assertNotConfigured); + } + + @Test + void whenNoTaskSchedulerDoesNotAutoConfigureHealthScheduler() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcServerHealthAutoConfiguration.class, + HealthContributorRegistryAutoConfiguration.class)) + .with(this::serviceBean) + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerHealthScheduler.class)); + } + + @Test + void whenHealthScheduleEnabledPropertyFalseDoesNotAutoConfigureHealthScheduler() { + this.contextRunner.withPropertyValues("spring.grpc.server.health.schedule.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(GrpcServerHealthScheduler.class)); + } + + @Test + void whenHealthScheduleEnabledPropertyTrueDoesAutoConfigureHealthScheduler() { + this.contextRunner.withPropertyValues("spring.grpc.server.health.schedule.enabled=true") + .run((context) -> assertThat(context).hasSingleBean(GrpcServerHealthScheduler.class)); + } + + @Test + void whenHealthEnabledPropertyIsTrueAutoConfiguresBeans() { + this.contextRunner.withPropertyValues("spring.grpc.server.health.enabled=true").run(this::assertConfigured); + } + + @Test + void whenHealthEnabledPropertyIsFalseDoesNotAutoConfigureBeans() { + this.contextRunner.withPropertyValues("spring.grpc.server.health.enabled=false").run(this::assertNotConfigured); + } + + @Test + void whenServerEnabledPropertyIsFalseDoesNotAutoConfigureBeans() { + this.contextRunner.withPropertyValues("spring.grpc.server.enabled=false").run(this::assertNotConfigured); + } + + @Test + void whenServerEnabledPropertyIsTrueAutoConfiguresBeans() { + this.contextRunner.withPropertyValues("spring.grpc.server.enabled=true").run(this::assertConfigured); + } + + @Test + void enterTerminalStateIsCalledOnShutdown() { + AtomicReference manager = new AtomicReference<>(); + this.contextRunner.run((context) -> { + manager.set(context.getBean(HealthStatusManager.class)); + assertTerminalState(manager.get(), false); + }); + assertTerminalState(manager.get(), true); + } + + @Test + void whenHasUserDefinedHealthStatusManagerDoesNotAutoConfigureBean() { + HealthStatusManager customHealthStatusManager = mock(); + this.contextRunner + .withBean("customHealthStatusManager", HealthStatusManager.class, () -> customHealthStatusManager) + .run((context) -> assertThat(context).getBean(HealthStatusManager.class) + .isSameAs(customHealthStatusManager)); + } + + @Test + void whenHasNoHealthContributorRegistryOnlyAutoConfiguresBasicService() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GrpcServerHealthAutoConfiguration.class, + TaskSchedulingAutoConfiguration.class)) + .with(this::serviceBean) + .run((context) -> { + assertThat(context).hasSingleBean(HealthStatusManager.class); + assertThat(context).hasBean("grpcServerHealthService"); + assertThat(context).doesNotHaveBean(GrpcServerHealth.class); + }); + } + + @Test + void createsStatusAggregatorFromProperties() { + this.contextRunner.withPropertyValues("spring.grpc.server.health.status.order=up,down").run((context) -> { + StatusAggregator aggregator = context.getBean(StatusAggregator.class); + assertThat(aggregator.getAggregateStatus(Status.UP, Status.DOWN)).isEqualTo(Status.UP); + }); + } + + @Test + void whenHasStatusAggregatorBeanIgnoresProperties() { + this.contextRunner.withUserConfiguration(StatusAggregatorConfiguration.class) + .withPropertyValues("spring.grpc.server.health.status.order=up,down") + .run((context) -> { + StatusAggregator aggregator = context.getBean(StatusAggregator.class); + assertThat(aggregator.getAggregateStatus(Status.UP, Status.DOWN)).isEqualTo(Status.UNKNOWN); + }); + } + + @Test + void createsStatusMapperFromProperties() { + this.contextRunner.withPropertyValues("spring.grpc.server.health.status.mapping.up=not-serving") + .run((context) -> { + StatusMapper mapper = context.getBean(StatusMapper.class); + assertThat(mapper.getServingStatus(Status.UP)).isEqualTo(ServingStatus.NOT_SERVING); + }); + } + + @Test + void whenHasHttpCodeStatusMapperBeanIgnoresProperties() { + this.contextRunner.withUserConfiguration(StatusMapperConfiguration.class) + .withPropertyValues("spring.grpc.server.health.status.mapping.up=not-serving") + .run((context) -> { + StatusMapper mapper = context.getBean(StatusMapper.class); + assertThat(mapper.getServingStatus(Status.UP)).isEqualTo(ServingStatus.UNRECOGNIZED); + }); + } + + @Test + void createsHealthCheckedGrpcComponents() { + this.contextRunner.withPropertyValues("spring.grpc.server.health.service.test.include=*").run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + assertThat(components).isInstanceOf(AutoConfiguredHealthCheckedGrpcComponents.class); + assertThat(components.getServiceNames()).containsOnly("test"); + }); + } + + @Test + void whenComponentsIncludesContributorThatExistsDoesNotFail() { + this.contextRunner.withUserConfiguration(CompositeHealthIndicatorConfiguration.class) + .withPropertyValues("spring.grpc.server.health.service.test.include=composite/b/c") + .run((context) -> assertThat(context).hasNotFailed()); + } + + @Test + void whenHealthCheckedGrpcComponentsIncludesReactiveContributorThatExists() { + this.contextRunner.withUserConfiguration(CompositeReactiveHealthIndicatorConfiguration.class) + .withPropertyValues("spring.grpc.server.health.service.test.include=composite/b/c") + .run((context) -> assertThat(context).hasNotFailed()); + } + + @Test + void whenHealthCheckedGrpcComponentsIncludesContributorThatDoesNotExistThrowsException() { + this.contextRunner.withUserConfiguration(CompositeHealthIndicatorConfiguration.class) + .withPropertyValues("spring.grpc.server.health.service.test.include=composite/b/c,nope") + .run((context) -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).isInstanceOf(FailureAnalyzedException.class) + .hasMessage("Health contributor 'nope' defined in " + + "'spring.grpc.server.health.service.test.include' does not exist"); + }); + } + + @Test + void whenHealthCheckedGrpcComponentsExcludesContributorThatDoesNotExistThrowsException() { + this.contextRunner + .withPropertyValues("spring.grpc.server.health.service.test.exclude=composite/b/d", + "spring.grpc.server.health.service.test.include=*") + .run((context) -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).isInstanceOf(FailureAnalyzedException.class) + .hasMessage("Health contributor 'composite/b/d' defined in " + + "'spring.grpc.server.health.service.test.exclude' does not exist"); + }); + } + + @Test + void whenHealthCheckedGrpcComponentsIncludesContributorThatDoesNotExistAndValidationDisabledCreatesComponents() { + this.contextRunner + .withPropertyValues("spring.grpc.server.health.services.validate-membership=false", + "spring.grpc.server.health.service.test.include=nope") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + assertThat(components).isInstanceOf(AutoConfiguredHealthCheckedGrpcComponents.class); + assertThat(components.getServiceNames()).containsOnly("test"); + }); + } + + @Test + void whenHasHealthCheckedGrpcComponentsBeanDoesNotCreateAdditional() { + this.contextRunner.withUserConfiguration(HealthCheckedGrpcComponentsConfiguration.class) + .withPropertyValues("spring.grpc.server.health.service.test.include=*") + .run((context) -> { + HealthCheckedGrpcComponents components = context.getBean(HealthCheckedGrpcComponents.class); + assertThat(components.getServiceNames()).containsOnly("mock"); + }); + } + + @Test + void createsGrpcServerHealth() { + this.contextRunner.run((context) -> { + GrpcServerHealth serverHealth = context.getBean(GrpcServerHealth.class); + Map result = new LinkedHashMap<>(); + serverHealth.update(result::put); + assertThat(result).containsExactly(entry("", ServingStatus.SERVING)); + }); + } + + @Test + void whenHasGrpcServerHealthBeanDoesNotCreateAdditional() { + this.contextRunner.withUserConfiguration(GrpcServerHealthConfiguration.class).run((context) -> { + GrpcServerHealth serverHealth = context.getBean(GrpcServerHealth.class); + Map result = new LinkedHashMap<>(); + serverHealth.update(result::put); + assertThat(result).isEmpty(); + }); + } + + @Test + void runWithIndicatorsInParentContextFindsIndicators() { + new ApplicationContextRunner().withUserConfiguration(DownHealthIndicatorConfiguration.class) + .run((parent) -> new ApplicationContextRunner().withConfiguration(autoConfigurations) + .withUserConfiguration(HealthIndicatorsConfiguration.class) + .with(this::serviceBean) + .withParent(parent) + .run((context) -> { + GrpcServerHealth serverHealth = context.getBean(GrpcServerHealth.class); + Map result = new LinkedHashMap<>(); + serverHealth.update(result::put); + assertThat(result).containsExactly(entry("", ServingStatus.NOT_SERVING)); + })); + } + + private void assertTerminalState(HealthStatusManager healthStatusManager, boolean expected) { + assertThat(healthStatusManager).extracting("healthService.terminal").isEqualTo(expected); + } + + private void assertConfigured(AssertableApplicationContext context) { + assertThat(context).hasSingleBean(HealthStatusManager.class); + assertThat(context).hasBean("grpcServerHealthService"); + assertThat(context).hasSingleBean(StatusAggregator.class); + assertThat(context).hasSingleBean(StatusMapper.class); + assertThat(context).hasSingleBean(HealthCheckedGrpcComponents.class); + assertThat(context).hasSingleBean(GrpcServerHealth.class); + assertThat(context).hasSingleBean(HealthContributorMembershipValidator.class); + assertThat(context).hasSingleBean(GrpcServerHealthScheduler.class); + } + + private void assertNotConfigured(AssertableApplicationContext context) { + assertThat(context).doesNotHaveBean(HealthStatusManager.class); + assertThat(context).doesNotHaveBean("grpcServerHealthService"); + assertThat(context).doesNotHaveBean(GrpcServerHealthAutoConfiguration.class); + } + + private , C extends ConfigurableApplicationContext, A extends ApplicationContextAssertProvider> R serviceBean( + R contextRunner) { + return contextRunner.withBean(BindableService.class, () -> this.service); + } + + @Configuration(proxyBeanMethods = false) + static class HealthIndicatorsConfiguration { + + @Bean + HealthIndicator simpleHealthIndicator() { + return () -> Health.up().withDetail("counter", 42).build(); + } + + @Bean + HealthIndicator additionalHealthIndicator() { + return () -> Health.up().build(); + } + + @Bean + ReactiveHealthIndicator reactiveHealthIndicator() { + return () -> Mono.just(Health.up().build()); + } + + } + + @Configuration(proxyBeanMethods = false) + static class DownHealthIndicatorConfiguration { + + @Bean + HealthIndicator downHealthIndicator() { + return () -> Health.down().build(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CompositeHealthIndicatorConfiguration { + + @Bean + CompositeHealthContributor compositeHealthIndicator() { + return CompositeHealthContributor.fromMap(Map.of("a", createHealthIndicator(), "b", + CompositeHealthContributor.fromMap(Map.of("c", createHealthIndicator())))); + } + + private HealthIndicator createHealthIndicator() { + return () -> Health.up().build(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CompositeReactiveHealthIndicatorConfiguration { + + @Bean + CompositeReactiveHealthContributor compositeHealthIndicator() { + return CompositeReactiveHealthContributor.fromMap(Map.of("a", createHealthIndicator(), "b", + CompositeReactiveHealthContributor.fromMap(Map.of("c", createHealthIndicator())))); + } + + private ReactiveHealthIndicator createHealthIndicator() { + return () -> Mono.just(Health.up().build()); + } + + } + + @Configuration(proxyBeanMethods = false) + static class StatusAggregatorConfiguration { + + @Bean + StatusAggregator statusAggregator() { + return (statuses) -> Status.UNKNOWN; + } + + } + + @Configuration(proxyBeanMethods = false) + static class StatusMapperConfiguration { + + @Bean + StatusMapper httpCodeStatusMapper() { + return (status) -> ServingStatus.UNRECOGNIZED; + } + + } + + @Configuration(proxyBeanMethods = false) + static class HealthCheckedGrpcComponentsConfiguration { + + @Bean + HealthCheckedGrpcComponents healthCheckedGrpcComponents() { + HealthCheckedGrpcComponents components = mock(); + given(components.getServiceNames()).willReturn(Collections.singleton("mock")); + return components; + } + + } + + @Configuration(proxyBeanMethods = false) + static class GrpcServerHealthConfiguration { + + @Bean + GrpcServerHealth grpcServerHealth() { + return mock(); + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthSchedulerTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthSchedulerTests.java new file mode 100644 index 00000000000..c054fc87663 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/autoconfigure/health/GrpcServerHealthSchedulerTests.java @@ -0,0 +1,79 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.autoconfigure.health; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +import io.grpc.protobuf.services.HealthStatusManager; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.boot.grpc.server.health.GrpcServerHealth; +import org.springframework.grpc.server.lifecycle.GrpcServerStartedEvent; +import org.springframework.scheduling.TaskScheduler; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; + +/** + * Tests for {@link GrpcServerHealthScheduler}. + * + * @author Phillip Webb + */ +class GrpcServerHealthSchedulerTests { + + @Test + void onApplicationEventWhenEventIsGrpcStartStartsHealth() { + Clock clock = Clock.fixed(Instant.now(), ZoneId.systemDefault()); + GrpcServerHealth serverHealth = mock(); + HealthStatusManager statusManager = mock(); + TaskScheduler taskScheduler = mock(); + Duration period = Duration.ofSeconds(10); + Duration delay = Duration.ofSeconds(30); + GrpcServerHealthScheduler healthScheduler = new GrpcServerHealthScheduler(clock, serverHealth, statusManager, + taskScheduler, period, delay); + then(serverHealth).should(never()).update(statusManager); + healthScheduler.onApplicationEvent(new GrpcServerStartedEvent(mock(), mock(), "localhost", 123)); + Instant startTime = Instant.now(clock).plus(delay); + ArgumentCaptor runnable = ArgumentCaptor.captor(); + then(taskScheduler).should().scheduleAtFixedRate(runnable.capture(), eq(startTime), eq(period)); + then(serverHealth).should(never()).update(statusManager); + runnable.getValue().run(); + then(serverHealth).should().update(statusManager); + } + + @Test + void onApplicationEventWhenEventIsGrpcStartAndCalledTwiceStartsHealthOnlyOnce() { + GrpcServerHealth serverHealth = mock(); + HealthStatusManager statusManager = mock(); + TaskScheduler taskScheduler = mock(); + GrpcServerHealthScheduler healthScheduler = new GrpcServerHealthScheduler(serverHealth, statusManager, + taskScheduler, Duration.ofSeconds(10), Duration.ofSeconds(30)); + then(serverHealth).should(never()).update(statusManager); + healthScheduler.onApplicationEvent(new GrpcServerStartedEvent(mock(), mock(), "localhost", 123)); + healthScheduler.onApplicationEvent(new GrpcServerStartedEvent(mock(), mock(), "localhost", 345)); + then(taskScheduler).should(times(1)).scheduleAtFixedRate(any(), any(), any()); + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/GrpcServerHealthTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/GrpcServerHealthTests.java new file mode 100644 index 00000000000..9a57df1153a --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/GrpcServerHealthTests.java @@ -0,0 +1,293 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.health; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.boot.health.contributor.CompositeHealthContributor; +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthContributor; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.boot.health.contributor.ReactiveHealthIndicator; +import org.springframework.boot.health.contributor.Status; +import org.springframework.boot.health.registry.DefaultHealthContributorRegistry; +import org.springframework.boot.health.registry.DefaultReactiveHealthContributorRegistry; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.entry; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GrpcServerHealth}. + * + * @author Phillip Webb + */ +class GrpcServerHealthTests { + + private static final HealthIndicator UP = () -> Health.up().build(); + + private static final HealthIndicator DOWN = () -> Health.down().build(); + + private static final ReactiveHealthIndicator REACTIVE_DOWN = () -> Mono.just(Health.down().build()); + + @Test + @SuppressWarnings("NullAway") // Test null check + void createWhenRegistryIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new GrpcServerHealth(null, null, mock())) + .withMessage("'registry' must not be null"); + } + + @Test + @SuppressWarnings("NullAway") // Test null check + void createWhenComponentsIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new GrpcServerHealth(mock(), null, null)) + .withMessage("'components' must not be null"); + } + + @Test + void updateWhenHasServerComponentAndUp() { + assertThat(updateWithServerComponent("test", UP)).containsExactly(entry("", ServingStatus.SERVING)); + } + + @Test + void updateWhenHasServerComponentAndDown() { + assertThat(updateWithServerComponent("test", DOWN)).containsExactly(entry("", ServingStatus.NOT_SERVING)); + } + + @Test + void updateWhenHasServerComponentAndMissing() { + assertThat(updateWithServerComponent("other", UP)).containsExactly(entry("", ServingStatus.UNKNOWN)); + } + + private Map updateWithServerComponent(String indicatorName, HealthIndicator indicator) { + HealthCheckedGrpcComponent server = new TestHealthCheckedGrpcComponent(Set.of("test")); + HealthCheckedGrpcComponents components = new TestHealthCheckedGrpcComponents(server, Collections.emptyMap()); + DefaultHealthContributorRegistry registry = new DefaultHealthContributorRegistry(); + registry.registerContributor(indicatorName, indicator); + GrpcServerHealth health = new GrpcServerHealth(registry, null, components); + Map result = new LinkedHashMap<>(); + health.update(result::put); + return result; + } + + @Test + void updateWhenHasServices() { + Map services = new LinkedHashMap<>(); + services.put("one", new TestHealthCheckedGrpcComponent(Set.of("up"))); + services.put("two", new TestHealthCheckedGrpcComponent(Set.of("down"))); + HealthCheckedGrpcComponents components = new TestHealthCheckedGrpcComponents(null, services); + DefaultHealthContributorRegistry registry = new DefaultHealthContributorRegistry(); + registry.registerContributor("up", UP); + registry.registerContributor("down", DOWN); + GrpcServerHealth health = new GrpcServerHealth(registry, null, components); + Map result = new LinkedHashMap<>(); + health.update(result::put); + assertThat(result).containsExactly(entry("one", ServingStatus.SERVING), + entry("two", ServingStatus.NOT_SERVING)); + } + + @Test + void updateUsesCache() { + Map services = new LinkedHashMap<>(); + services.put("one", new TestHealthCheckedGrpcComponent(Set.of("test"))); + services.put("two", new TestHealthCheckedGrpcComponent(Set.of("test"))); + HealthCheckedGrpcComponents components = new TestHealthCheckedGrpcComponents(null, services); + DefaultHealthContributorRegistry registry = new DefaultHealthContributorRegistry(); + HealthIndicator contributor = mock(); + given(contributor.health(false)).willReturn(Health.up().build(), Health.down().build()); + registry.registerContributor("test", contributor); + GrpcServerHealth health = new GrpcServerHealth(registry, null, components); + Map result = new LinkedHashMap<>(); + health.update(result::put); + assertThat(result).containsExactly(entry("one", ServingStatus.SERVING), entry("two", ServingStatus.SERVING)); + } + + @Test + void updateWhenHasEmptyNamedServicesDoesNotIncludeIt() { + Map services = new LinkedHashMap<>(); + services.put("", new TestHealthCheckedGrpcComponent(Set.of("up"))); + services.put("one", new TestHealthCheckedGrpcComponent(Set.of("up"))); + HealthCheckedGrpcComponents components = new TestHealthCheckedGrpcComponents(null, services); + DefaultHealthContributorRegistry registry = new DefaultHealthContributorRegistry(); + registry.registerContributor("up", UP); + GrpcServerHealth health = new GrpcServerHealth(registry, null, components); + Map result = new LinkedHashMap<>(); + health.update(result::put); + assertThat(result).containsExactly(entry("one", ServingStatus.SERVING)); + } + + @Test + void updateWhenHasFallbackRegistry() { + Map services = new LinkedHashMap<>(); + services.put("one", new TestHealthCheckedGrpcComponent(Set.of("1"))); + services.put("two", new TestHealthCheckedGrpcComponent(Set.of("2"))); + services.put("three", new TestHealthCheckedGrpcComponent(Set.of("3"))); + HealthCheckedGrpcComponents components = new TestHealthCheckedGrpcComponents(null, services); + DefaultHealthContributorRegistry registry = new DefaultHealthContributorRegistry(); + DefaultReactiveHealthContributorRegistry fallbackRegistry = new DefaultReactiveHealthContributorRegistry(); + registry.registerContributor("1", UP); + registry.registerContributor("2", UP); + fallbackRegistry.registerContributor("2", REACTIVE_DOWN); + fallbackRegistry.registerContributor("3", REACTIVE_DOWN); + GrpcServerHealth health = new GrpcServerHealth(registry, fallbackRegistry, components); + Map result = new LinkedHashMap<>(); + health.update(result::put); + assertThat(result).containsExactly(entry("one", ServingStatus.SERVING), entry("two", ServingStatus.SERVING), + entry("three", ServingStatus.NOT_SERVING)); + } + + @Test + void updateWhenHasCompositeContributor() { + Map services = new LinkedHashMap<>(); + services.put("one", new TestHealthCheckedGrpcComponent(Set.of("dbs", "dbs/db1", "dbs/db2"))); + HealthCheckedGrpcComponents components = new TestHealthCheckedGrpcComponents(null, services); + HealthContributor contributor = CompositeHealthContributor.fromMap(Map.of("db1", UP, "db2", DOWN)); + DefaultHealthContributorRegistry registry = new DefaultHealthContributorRegistry(); + registry.registerContributor("dbs", contributor); + GrpcServerHealth health = new GrpcServerHealth(registry, null, components); + Map result = new LinkedHashMap<>(); + health.update(result::put); + assertThat(result).containsExactly(entry("one", ServingStatus.NOT_SERVING)); + } + + @Test + void updateFiltersIndicatorsAndCompositeParentByName() { + Map services = new LinkedHashMap<>(); + services.put("one", new TestHealthCheckedGrpcComponent(Set.of("dbs", "dbs/db1"))); + HealthCheckedGrpcComponents components = new TestHealthCheckedGrpcComponents(null, services); + HealthContributor contributor = CompositeHealthContributor.fromMap(Map.of("db1", UP, "db2", DOWN)); + DefaultHealthContributorRegistry registry = new DefaultHealthContributorRegistry(); + registry.registerContributor("dbs", contributor); + GrpcServerHealth health = new GrpcServerHealth(registry, null, components); + Map result = new LinkedHashMap<>(); + health.update(result::put); + assertThat(result).containsExactly(entry("one", ServingStatus.SERVING)); + } + + @Test + @SuppressWarnings("unchecked") + void updateCallsStatusAggregator() { + StatusAggregator statusAggregator = mock(); + given(statusAggregator.getAggregateStatus((Set) any(Set.class))).willReturn(Status.DOWN); + Map services = new LinkedHashMap<>(); + services.put("one", + new TestHealthCheckedGrpcComponent(Set.of("up"), statusAggregator, StatusMapper.getDefault())); + HealthCheckedGrpcComponents components = new TestHealthCheckedGrpcComponents(null, services); + DefaultHealthContributorRegistry registry = new DefaultHealthContributorRegistry(); + registry.registerContributor("up", UP); + GrpcServerHealth health = new GrpcServerHealth(registry, null, components); + Map result = new LinkedHashMap<>(); + health.update(result::put); + assertThat(result).containsExactly(entry("one", ServingStatus.NOT_SERVING)); + then(statusAggregator).should().getAggregateStatus(Set.of(Status.UP)); + } + + @Test + void updateCallsStatusMapper() { + StatusMapper statusMapper = mock(); + given(statusMapper.getServingStatus(Status.UP)).willReturn(ServingStatus.UNRECOGNIZED); + Map services = new LinkedHashMap<>(); + services.put("one", + new TestHealthCheckedGrpcComponent(Set.of("up"), StatusAggregator.getDefault(), statusMapper)); + HealthCheckedGrpcComponents components = new TestHealthCheckedGrpcComponents(null, services); + DefaultHealthContributorRegistry registry = new DefaultHealthContributorRegistry(); + registry.registerContributor("up", UP); + GrpcServerHealth health = new GrpcServerHealth(registry, null, components); + Map result = new LinkedHashMap<>(); + health.update(result::put); + assertThat(result).containsExactly(entry("one", ServingStatus.UNRECOGNIZED)); + then(statusMapper).should().getServingStatus(Status.UP); + } + + static class TestHealthCheckedGrpcComponents implements HealthCheckedGrpcComponents { + + private final @Nullable HealthCheckedGrpcComponent server; + + private final Map services; + + TestHealthCheckedGrpcComponents(@Nullable HealthCheckedGrpcComponent server, + Map services) { + this.server = server; + this.services = services; + } + + @Override + public @Nullable HealthCheckedGrpcComponent getServer() { + return this.server; + } + + @Override + public Set getServiceNames() { + return this.services.keySet(); + } + + @Override + public @Nullable HealthCheckedGrpcComponent getService(String serviceName) { + return this.services.get(serviceName); + } + + } + + private static class TestHealthCheckedGrpcComponent implements HealthCheckedGrpcComponent { + + private final Set members; + + private final StatusAggregator statusAggregator; + + private final StatusMapper statusMapper; + + TestHealthCheckedGrpcComponent(Set memebers) { + this(memebers, StatusAggregator.getDefault(), StatusMapper.getDefault()); + } + + TestHealthCheckedGrpcComponent(Set memebers, StatusAggregator statusAggregator, + StatusMapper statusMapper) { + this.members = memebers; + this.statusAggregator = statusAggregator; + this.statusMapper = statusMapper; + } + + @Override + public boolean isMember(String healthContributorName) { + return this.members.contains(healthContributorName); + } + + @Override + public StatusAggregator getStatusAggregator() { + return this.statusAggregator; + } + + @Override + public StatusMapper getStatusMapper() { + return this.statusMapper; + } + + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/StatusAggregatorTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/StatusAggregatorTests.java new file mode 100644 index 00000000000..bbc8a6abe62 --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/StatusAggregatorTests.java @@ -0,0 +1,71 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.health; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.health.actuate.endpoint.StatusAggregator; +import org.springframework.boot.health.contributor.Status; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link StatusAggregator}. + * + * @author Phillip Webb + */ +class StatusAggregatorTests { + + @Test + void ofAndGetAggregateStatusWhenUsingDefaultInstance() { + StatusAggregator aggregator = StatusAggregator.getDefault(); + Status status = aggregator.getAggregateStatus(Status.DOWN, Status.UP, Status.UNKNOWN, Status.OUT_OF_SERVICE); + assertThat(status).isEqualTo(Status.DOWN); + } + + @Test + void ofAndGetAggregateStatusWhenUsingCustomOrder() { + StatusAggregator aggregator = StatusAggregator.of(Status.UNKNOWN, Status.UP, Status.OUT_OF_SERVICE, + Status.DOWN); + Status status = aggregator.getAggregateStatus(Status.DOWN, Status.UP, Status.UNKNOWN, Status.OUT_OF_SERVICE); + assertThat(status).isEqualTo(Status.UNKNOWN); + } + + @Test + void ofAndGetAggregateStatusWhenHasCustomStatusAndUsingDefaultOrder() { + StatusAggregator aggregator = StatusAggregator.getDefault(); + Status status = aggregator.getAggregateStatus(Status.DOWN, Status.UP, Status.UNKNOWN, Status.OUT_OF_SERVICE, + new Status("CUSTOM")); + assertThat(status).isEqualTo(Status.DOWN); + } + + @Test + void ofAndGetAggregateStatusWhenHasCustomStatusAndUsingCustomOrder() { + StatusAggregator aggregator = StatusAggregator.of("DOWN", "OUT_OF_SERVICE", "UP", "UNKNOWN", "CUSTOM"); + Status status = aggregator.getAggregateStatus(Status.DOWN, Status.UP, Status.UNKNOWN, Status.OUT_OF_SERVICE, + new Status("CUSTOM")); + assertThat(status).isEqualTo(Status.DOWN); + } + + @Test + void ofWithNonUniformCodes() { + StatusAggregator aggregator = StatusAggregator.of("out-of-service", "up"); + Status status = aggregator.getAggregateStatus(Status.UP, Status.OUT_OF_SERVICE); + assertThat(status).isEqualTo(Status.OUT_OF_SERVICE); + } + +} diff --git a/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/StatusMapperTests.java b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/StatusMapperTests.java new file mode 100644 index 00000000000..af027f7200c --- /dev/null +++ b/module/spring-boot-grpc-server/src/test/java/org/springframework/boot/grpc/server/health/StatusMapperTests.java @@ -0,0 +1,64 @@ +/* + * Copyright 2012-present 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.boot.grpc.server.health; + +import java.util.LinkedHashMap; +import java.util.Map; + +import io.grpc.health.v1.HealthCheckResponse.ServingStatus; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.health.contributor.Status; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link StatusMapper}. + * + * @author Phillip Webb + */ +class StatusMapperTests { + + @Test + void createWhenMappingsAreNullUsesDefaultMappings() { + StatusMapper mapper = StatusMapper.of(null); + assertThat(mapper.getServingStatus(Status.UNKNOWN)).isEqualTo(ServingStatus.UNKNOWN); + assertThat(mapper.getServingStatus(Status.UP)).isEqualTo(ServingStatus.SERVING); + assertThat(mapper.getServingStatus(Status.DOWN)).isEqualTo(ServingStatus.NOT_SERVING); + assertThat(mapper.getServingStatus(Status.OUT_OF_SERVICE)).isEqualTo(ServingStatus.NOT_SERVING); + } + + @Test + void getStatusCodeReturnsMappedStatus() { + Map map = new LinkedHashMap<>(); + map.put("up", ServingStatus.UNRECOGNIZED); + map.put("down", ServingStatus.UNKNOWN); + StatusMapper mapper = StatusMapper.of(map); + assertThat(mapper.getServingStatus(Status.UP)).isEqualTo(ServingStatus.UNRECOGNIZED); + assertThat(mapper.getServingStatus(Status.DOWN)).isEqualTo(ServingStatus.UNKNOWN); + assertThat(mapper.getServingStatus(Status.OUT_OF_SERVICE)).isEqualTo(ServingStatus.SERVING); + } + + @Test + void getStatusCodeWhenMappingsAreNotUniformReturnsMappedStatus() { + Map map = new LinkedHashMap<>(); + map.put("out-of-service", ServingStatus.SERVING); + StatusMapper mapper = StatusMapper.of(map); + assertThat(mapper.getServingStatus(Status.OUT_OF_SERVICE)).isEqualTo(ServingStatus.SERVING); + } + +}