diff --git a/spring-cloud-cloudfoundry-discovery/pom.xml b/spring-cloud-cloudfoundry-discovery/pom.xml index 535e429..445f620 100644 --- a/spring-cloud-cloudfoundry-discovery/pom.xml +++ b/spring-cloud-cloudfoundry-discovery/pom.xml @@ -18,11 +18,21 @@ spring-cloud-cloudfoundry-commons ${project.version} + + org.springframework.boot + spring-boot-actuator + true + org.springframework.boot spring-boot-configuration-processor true + + org.springframework.boot + spring-boot-starter-webflux + true + org.springframework.cloud spring-cloud-context @@ -51,5 +61,10 @@ spring-boot-starter-test test + + io.projectreactor + reactor-test + test + diff --git a/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/CloudFoundryDiscoveryClient.java b/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/CloudFoundryDiscoveryClient.java index 6160cb7..f673101 100644 --- a/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/CloudFoundryDiscoveryClient.java +++ b/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/CloudFoundryDiscoveryClient.java @@ -81,8 +81,8 @@ public class CloudFoundryDiscoveryClient implements DiscoveryClient { metadata.put("applicationId", applicationId); metadata.put("instanceId", applicationIndex); - return (ServiceInstance) new DefaultServiceInstance(instanceId, name, url, 80, - secure, metadata); + return (ServiceInstance) new DefaultServiceInstance(instanceId, name, url, + secure ? 443 : 80, secure, metadata); }).collectList().blockOptional().orElse(new ArrayList<>()); } diff --git a/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/CloudFoundryDiscoveryClientConfiguration.java b/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/CloudFoundryDiscoveryClientConfiguration.java index 1649c21..15e7432 100644 --- a/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/CloudFoundryDiscoveryClientConfiguration.java +++ b/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/CloudFoundryDiscoveryClientConfiguration.java @@ -24,6 +24,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.client.ConditionalOnBlockingDiscoveryEnabled; +import org.springframework.cloud.client.ConditionalOnDiscoveryEnabled; import org.springframework.cloud.client.discovery.DiscoveryClient; import org.springframework.cloud.cloudfoundry.CloudFoundryService; import org.springframework.cloud.cloudfoundry.discovery.SimpleDnsBasedDiscoveryClient.ServiceIdToHostnameConverter; @@ -35,8 +37,9 @@ import org.springframework.context.annotation.Configuration; */ @Configuration @ConditionalOnClass(CloudFoundryOperations.class) -@ConditionalOnProperty(value = "spring.cloud.cloudfoundry.discovery.enabled", - matchIfMissing = true) +@ConditionalOnDiscoveryEnabled +@ConditionalOnBlockingDiscoveryEnabled +@ConditionalOnCloudFoundryDiscoveryEnabled @EnableConfigurationProperties(CloudFoundryDiscoveryProperties.class) public class CloudFoundryDiscoveryClientConfiguration { diff --git a/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/ConditionalOnCloudFoundryDiscoveryEnabled.java b/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/ConditionalOnCloudFoundryDiscoveryEnabled.java new file mode 100644 index 0000000..82afcd1 --- /dev/null +++ b/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/ConditionalOnCloudFoundryDiscoveryEnabled.java @@ -0,0 +1,43 @@ +/* + * Copyright 2019-2019 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.cloud.cloudfoundry.discovery; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +/** + * Provides a more succinct conditional + * spring.cloud.cloudfoundry.discovery.enabled. + * + * @author Tim Ysewyn + * @since 2.2.0 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@ConditionalOnProperty(value = "spring.cloud.cloudfoundry.discovery.enabled", + matchIfMissing = true) +public @interface ConditionalOnCloudFoundryDiscoveryEnabled { + +} diff --git a/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryAppServiceReactiveDiscoveryClient.java b/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryAppServiceReactiveDiscoveryClient.java new file mode 100644 index 0000000..73f76d9 --- /dev/null +++ b/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryAppServiceReactiveDiscoveryClient.java @@ -0,0 +1,68 @@ +/* + * Copyright 2019-2019 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.cloud.cloudfoundry.discovery.reactive; + +import org.cloudfoundry.operations.CloudFoundryOperations; +import reactor.core.publisher.Flux; + +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.cloudfoundry.CloudFoundryService; +import org.springframework.cloud.cloudfoundry.discovery.CloudFoundryDiscoveryProperties; + +/** + * Discovery Client implementation using Cloud Foundry's Native DNS based Service + * Discovery. + * + * @author Tim Ysewyn + * @see CF App Service + * Discovery Release + * @see Polyglot + * Service Discovery for Container Networking in Cloud Foundry + */ +public class CloudFoundryAppServiceReactiveDiscoveryClient + extends CloudFoundryNativeReactiveDiscoveryClient { + + private static final String INTERNAL_DOMAIN = "apps.internal"; + + private final CloudFoundryService cloudFoundryService; + + CloudFoundryAppServiceReactiveDiscoveryClient( + CloudFoundryOperations cloudFoundryOperations, CloudFoundryService svc, + CloudFoundryDiscoveryProperties cloudFoundryDiscoveryProperties) { + super(cloudFoundryOperations, svc, cloudFoundryDiscoveryProperties); + this.cloudFoundryService = svc; + } + + @Override + public String description() { + return "CF App Reactive Service Discovery Client"; + } + + @Override + public Flux getInstances(String serviceId) { + return cloudFoundryService.getApplicationInstances(serviceId) + .filter(tuple -> tuple.getT1().getUrls().stream() + .anyMatch(this::isInternalDomain)) + .map(this::mapApplicationInstanceToServiceInstance); + } + + private boolean isInternalDomain(String url) { + return url != null && url.endsWith(INTERNAL_DOMAIN); + } + +} diff --git a/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryNativeReactiveDiscoveryClient.java b/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryNativeReactiveDiscoveryClient.java new file mode 100644 index 0000000..fd49171 --- /dev/null +++ b/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryNativeReactiveDiscoveryClient.java @@ -0,0 +1,99 @@ +/* + * Copyright 2019-2019 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.cloud.cloudfoundry.discovery.reactive; + +import java.util.HashMap; + +import org.cloudfoundry.operations.CloudFoundryOperations; +import org.cloudfoundry.operations.applications.ApplicationDetail; +import org.cloudfoundry.operations.applications.ApplicationSummary; +import org.cloudfoundry.operations.applications.InstanceDetail; +import reactor.core.publisher.Flux; +import reactor.util.function.Tuple2; + +import org.springframework.cloud.client.DefaultServiceInstance; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.discovery.ReactiveDiscoveryClient; +import org.springframework.cloud.cloudfoundry.CloudFoundryService; +import org.springframework.cloud.cloudfoundry.discovery.CloudFoundryDiscoveryProperties; + +/** + * Cloud Foundry maintains a registry of running applications which we expose here as + * CloudFoundryService instances. + * + * @author Tim Ysewyn + */ +public class CloudFoundryNativeReactiveDiscoveryClient + implements ReactiveDiscoveryClient { + + private final CloudFoundryService cloudFoundryService; + + private final CloudFoundryOperations cloudFoundryOperations; + + private final CloudFoundryDiscoveryProperties properties; + + CloudFoundryNativeReactiveDiscoveryClient(CloudFoundryOperations operations, + CloudFoundryService svc, CloudFoundryDiscoveryProperties properties) { + this.cloudFoundryService = svc; + this.cloudFoundryOperations = operations; + this.properties = properties; + } + + @Override + public String description() { + return "CF Reactive Service Discovery Client"; + } + + @Override + public Flux getInstances(String serviceId) { + return this.cloudFoundryService.getApplicationInstances(serviceId) + .map(this::mapApplicationInstanceToServiceInstance); + } + + @Override + public Flux getServices() { + return this.cloudFoundryOperations.applications().list() + .map(ApplicationSummary::getName); + } + + @Override + public int getOrder() { + return this.properties.getOrder(); + } + + protected ServiceInstance mapApplicationInstanceToServiceInstance( + Tuple2 tuple) { + ApplicationDetail applicationDetail = tuple.getT1(); + InstanceDetail instanceDetail = tuple.getT2(); + + String applicationId = applicationDetail.getId(); + String applicationIndex = instanceDetail.getIndex(); + String instanceId = applicationId + "." + applicationIndex; + String name = applicationDetail.getName(); + String url = applicationDetail.getUrls().size() > 0 + ? applicationDetail.getUrls().get(0) : null; + boolean secure = (url + "").toLowerCase().startsWith("https"); + + HashMap metadata = new HashMap<>(); + metadata.put("applicationId", applicationId); + metadata.put("instanceId", applicationIndex); + + return new DefaultServiceInstance(instanceId, name, url, secure ? 443 : 80, + secure, metadata); + } + +} diff --git a/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryReactiveDiscoveryClientConfiguration.java b/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryReactiveDiscoveryClientConfiguration.java new file mode 100644 index 0000000..b3a3e8c --- /dev/null +++ b/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryReactiveDiscoveryClientConfiguration.java @@ -0,0 +1,164 @@ +/* + * Copyright 2019-2019 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.cloud.cloudfoundry.discovery.reactive; + +import org.cloudfoundry.operations.CloudFoundryOperations; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.client.ConditionalOnDiscoveryEnabled; +import org.springframework.cloud.client.ConditionalOnDiscoveryHealthIndicatorEnabled; +import org.springframework.cloud.client.ConditionalOnReactiveDiscoveryEnabled; +import org.springframework.cloud.client.ReactiveCommonsClientAutoConfiguration; +import org.springframework.cloud.client.discovery.composite.reactive.ReactiveCompositeDiscoveryClientAutoConfiguration; +import org.springframework.cloud.client.discovery.health.DiscoveryClientHealthIndicatorProperties; +import org.springframework.cloud.client.discovery.health.reactive.ReactiveDiscoveryClientHealthIndicator; +import org.springframework.cloud.cloudfoundry.CloudFoundryService; +import org.springframework.cloud.cloudfoundry.discovery.CloudFoundryDiscoveryProperties; +import org.springframework.cloud.cloudfoundry.discovery.ConditionalOnCloudFoundryDiscoveryEnabled; +import org.springframework.cloud.cloudfoundry.discovery.reactive.SimpleDnsBasedReactiveDiscoveryClient.ServiceIdToHostnameConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration related to service discovery when using Cloud Foundry. + * + * @author Tim Ysewyn + */ +@Configuration +@ConditionalOnClass(CloudFoundryOperations.class) +@ConditionalOnDiscoveryEnabled +@ConditionalOnReactiveDiscoveryEnabled +@ConditionalOnCloudFoundryDiscoveryEnabled +@EnableConfigurationProperties(CloudFoundryDiscoveryProperties.class) +@AutoConfigureAfter(ReactiveCompositeDiscoveryClientAutoConfiguration.class) +@AutoConfigureBefore(ReactiveCommonsClientAutoConfiguration.class) +public class CloudFoundryReactiveDiscoveryClientConfiguration { + + @Configuration + @ConditionalOnProperty(value = "spring.cloud.cloudfoundry.discovery.use-dns", + havingValue = "false", matchIfMissing = true) + public static class CloudFoundryNativeReactiveDiscoveryClientConfig { + + @Bean + @ConditionalOnMissingBean + public CloudFoundryNativeReactiveDiscoveryClient nativeCloudFoundryDiscoveryClient( + CloudFoundryOperations cf, CloudFoundryService svc, + CloudFoundryDiscoveryProperties cloudFoundryDiscoveryProperties) { + return new CloudFoundryNativeReactiveDiscoveryClient(cf, svc, + cloudFoundryDiscoveryProperties); + } + + @Bean + @ConditionalOnClass( + name = "org.springframework.boot.actuate.health.ReactiveHealthIndicator") + @ConditionalOnDiscoveryHealthIndicatorEnabled + public ReactiveDiscoveryClientHealthIndicator cloudFoundryReactiveDiscoveryClientHealthIndicator( + CloudFoundryNativeReactiveDiscoveryClient client, + DiscoveryClientHealthIndicatorProperties properties) { + return new ReactiveDiscoveryClientHealthIndicator(client, properties); + } + + @Bean + public CloudFoundryReactiveHeartbeatSender cloudFoundryHeartbeatSender( + CloudFoundryNativeReactiveDiscoveryClient client) { + return new CloudFoundryReactiveHeartbeatSender(client); + } + + } + + @Configuration + @ConditionalOnProperty(value = "spring.cloud.cloudfoundry.discovery.use-dns", + havingValue = "true") + public static class DnsBasedCloudFoundryReactiveDiscoveryClientConfig { + + @Configuration + @ConditionalOnProperty( + value = "spring.cloud.cloudfoundry.discovery.use-container-ip", + havingValue = "true") + public static class CloudFoundrySimpleDnsBasedReactiveDiscoveryClientConfig { + + @Bean + @ConditionalOnMissingBean + public SimpleDnsBasedReactiveDiscoveryClient dnsBasedReactiveDiscoveryClient( + ObjectProvider provider, + CloudFoundryDiscoveryProperties properties) { + ServiceIdToHostnameConverter converter = provider.getIfAvailable(); + return converter == null + ? new SimpleDnsBasedReactiveDiscoveryClient(properties) + : new SimpleDnsBasedReactiveDiscoveryClient(converter); + } + + @Bean + @ConditionalOnClass( + name = "org.springframework.boot.actuate.health.ReactiveHealthIndicator") + @ConditionalOnDiscoveryHealthIndicatorEnabled + public ReactiveDiscoveryClientHealthIndicator cloudFoundryReactiveDiscoveryClientHealthIndicator( + SimpleDnsBasedReactiveDiscoveryClient client, + DiscoveryClientHealthIndicatorProperties properties) { + return new ReactiveDiscoveryClientHealthIndicator(client, properties); + } + + @Bean + public CloudFoundryReactiveHeartbeatSender cloudFoundryHeartbeatSender( + SimpleDnsBasedReactiveDiscoveryClient client) { + return new CloudFoundryReactiveHeartbeatSender(client); + } + + } + + @Configuration + @ConditionalOnProperty( + value = "spring.cloud.cloudfoundry.discovery.use-container-ip", + havingValue = "false", matchIfMissing = true) + public static class CloudFoundryAppServiceReactiveDiscoveryClientConfig { + + @Bean + @ConditionalOnMissingBean + public CloudFoundryAppServiceReactiveDiscoveryClient appServiceReactiveDiscoveryClient( + CloudFoundryOperations cf, CloudFoundryService svc, + CloudFoundryDiscoveryProperties properties) { + return new CloudFoundryAppServiceReactiveDiscoveryClient(cf, svc, + properties); + } + + @Bean + @ConditionalOnClass( + name = "org.springframework.boot.actuate.health.ReactiveHealthIndicator") + @ConditionalOnDiscoveryHealthIndicatorEnabled + public ReactiveDiscoveryClientHealthIndicator cloudFoundryReactiveDiscoveryClientHealthIndicator( + CloudFoundryAppServiceReactiveDiscoveryClient client, + DiscoveryClientHealthIndicatorProperties properties) { + return new ReactiveDiscoveryClientHealthIndicator(client, properties); + } + + @Bean + public CloudFoundryReactiveHeartbeatSender cloudFoundryHeartbeatSender( + CloudFoundryAppServiceReactiveDiscoveryClient client) { + return new CloudFoundryReactiveHeartbeatSender(client); + } + + } + + } + +} diff --git a/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryReactiveHeartbeatSender.java b/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryReactiveHeartbeatSender.java new file mode 100644 index 0000000..e633f29 --- /dev/null +++ b/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryReactiveHeartbeatSender.java @@ -0,0 +1,58 @@ +/* + * Copyright 2013-2019 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.cloud.cloudfoundry.discovery.reactive; + +import org.springframework.cloud.client.discovery.ReactiveDiscoveryClient; +import org.springframework.cloud.client.discovery.event.HeartbeatEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationEventPublisherAware; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * Publishes a {@link HeartbeatEvent} with a `Flux` of services as its state indicator. If + * consumers detect a change it doesn't necessarily mean there is a change in the catalog. + * + * @author Tim Ysewyn + */ +@Component +public class CloudFoundryReactiveHeartbeatSender + implements ApplicationEventPublisherAware { + + private final ReactiveDiscoveryClient client; + + private ApplicationEventPublisher publisher; + + public CloudFoundryReactiveHeartbeatSender(ReactiveDiscoveryClient client) { + this.client = client; + } + + @Scheduled( + fixedDelayString = "${spring.cloud.cloudfoundry.discovery.heartbeatFrequency:5000}") + public void poll() { + if (this.publisher != null) { + this.publisher.publishEvent( + new HeartbeatEvent(this.client, this.client.getServices())); + } + } + + @Override + public void setApplicationEventPublisher(ApplicationEventPublisher publisher) { + this.publisher = publisher; + } + +} diff --git a/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/reactive/SimpleDnsBasedReactiveDiscoveryClient.java b/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/reactive/SimpleDnsBasedReactiveDiscoveryClient.java new file mode 100644 index 0000000..6d5d98e --- /dev/null +++ b/spring-cloud-cloudfoundry-discovery/src/main/java/org/springframework/cloud/cloudfoundry/discovery/reactive/SimpleDnsBasedReactiveDiscoveryClient.java @@ -0,0 +1,98 @@ +/* + * Copyright 2019-2019 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.cloud.cloudfoundry.discovery.reactive; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.function.Function; + +import org.reactivestreams.Publisher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.cloud.client.DefaultServiceInstance; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.discovery.ReactiveDiscoveryClient; +import org.springframework.cloud.cloudfoundry.discovery.CloudFoundryDiscoveryProperties; + +/** + * Reactive Discovery Client implementation using Cloud Foundry's Native DNS based Service + * Discovery. + * + * @author Tim Ysewyn + * @see Polyglot + * Service Discovery for Container Networking in Cloud Foundry + */ +public class SimpleDnsBasedReactiveDiscoveryClient implements ReactiveDiscoveryClient { + + private static final Logger log = LoggerFactory + .getLogger(SimpleDnsBasedReactiveDiscoveryClient.class); + + private final ServiceIdToHostnameConverter serviceIdToHostnameConverter; + + public SimpleDnsBasedReactiveDiscoveryClient( + ServiceIdToHostnameConverter serviceIdToHostnameConverter) { + this.serviceIdToHostnameConverter = serviceIdToHostnameConverter; + } + + public SimpleDnsBasedReactiveDiscoveryClient( + CloudFoundryDiscoveryProperties properties) { + this(serviceId -> serviceId + "." + properties.getInternalDomain()); + } + + @Override + public String description() { + return "DNS Based CF Service Reactive Discovery Client"; + } + + @Override + public Flux getInstances(String serviceId) { + return Mono.justOrEmpty(serviceIdToHostnameConverter.toHostname(serviceId)) + .flatMapMany(getInetAddresses()) + .map(address -> new DefaultServiceInstance(serviceId, + address.getHostAddress(), 8080, false)); + } + + private Function> getInetAddresses() { + return hostname -> { + try { + return Flux.fromArray(InetAddress.getAllByName(hostname)); + } + catch (UnknownHostException e) { + log.warn("{}", e.getMessage()); + return Flux.empty(); + } + }; + } + + @Override + public Flux getServices() { + log.warn("getServices is not supported"); + return Flux.empty(); + } + + @FunctionalInterface + public interface ServiceIdToHostnameConverter { + + String toHostname(String serviceId); + + } + +} diff --git a/spring-cloud-cloudfoundry-discovery/src/main/resources/META-INF/spring.factories b/spring-cloud-cloudfoundry-discovery/src/main/resources/META-INF/spring.factories index 07b467a..5cebac6 100644 --- a/spring-cloud-cloudfoundry-discovery/src/main/resources/META-INF/spring.factories +++ b/spring-cloud-cloudfoundry-discovery/src/main/resources/META-INF/spring.factories @@ -2,4 +2,5 @@ org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ org.springframework.cloud.cloudfoundry.discovery.RibbonCloudFoundryAutoConfiguration # Discovery Client Configuration org.springframework.cloud.client.discovery.EnableDiscoveryClient=\ -org.springframework.cloud.cloudfoundry.discovery.CloudFoundryDiscoveryClientConfiguration +org.springframework.cloud.cloudfoundry.discovery.CloudFoundryDiscoveryClientConfiguration, \ +org.springframework.cloud.cloudfoundry.discovery.reactive.CloudFoundryReactiveDiscoveryClientConfiguration diff --git a/spring-cloud-cloudfoundry-discovery/src/test/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryAppServiceReactiveDiscoveryClientTests.java b/spring-cloud-cloudfoundry-discovery/src/test/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryAppServiceReactiveDiscoveryClientTests.java new file mode 100644 index 0000000..443c4ff --- /dev/null +++ b/spring-cloud-cloudfoundry-discovery/src/test/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryAppServiceReactiveDiscoveryClientTests.java @@ -0,0 +1,72 @@ +/* + * Copyright 2019-2019 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.cloud.cloudfoundry.discovery.reactive; + +import java.util.UUID; + +import org.cloudfoundry.operations.applications.ApplicationDetail; +import org.cloudfoundry.operations.applications.InstanceDetail; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; +import reactor.util.function.Tuple2; +import reactor.util.function.Tuples; + +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.cloudfoundry.CloudFoundryService; + +import static org.mockito.Mockito.when; + +/** + * @author Tim Ysewyn + */ +@ExtendWith(MockitoExtension.class) +class CloudFoundryAppServiceReactiveDiscoveryClientTests { + + @Mock + private CloudFoundryService svc; + + @InjectMocks + private CloudFoundryAppServiceReactiveDiscoveryClient client; + + @Test + public void shouldReturnFluxOfServiceInstances() { + ApplicationDetail appDetail1 = ApplicationDetail.builder() + .id(UUID.randomUUID().toString()).stack("stack").instances(1) + .memoryLimit(1024).requestedState("requestedState").diskQuota(1024) + .name("service").runningInstances(1).url("instance.apps.internal") + .build(); + Tuple2 instance1 = Tuples.of(appDetail1, + InstanceDetail.builder().index("0").build()); + ApplicationDetail appDetail2 = ApplicationDetail.builder() + .id(UUID.randomUUID().toString()).stack("stack").instances(1) + .memoryLimit(1024).requestedState("requestedState").diskQuota(1024) + .name("service").runningInstances(1).url("instance.apps.not.internal") + .build(); + Tuple2 instance2 = Tuples.of(appDetail2, + InstanceDetail.builder().index("0").build()); + when(this.svc.getApplicationInstances("service")) + .thenReturn(Flux.just(instance1, instance2)); + Flux instances = this.client.getInstances("service"); + StepVerifier.create(instances).expectNextCount(1).expectComplete().verify(); + } + +} diff --git a/spring-cloud-cloudfoundry-discovery/src/test/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryNativeReactiveDiscoveryClientTests.java b/spring-cloud-cloudfoundry-discovery/src/test/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryNativeReactiveDiscoveryClientTests.java new file mode 100644 index 0000000..b8c8c59 --- /dev/null +++ b/spring-cloud-cloudfoundry-discovery/src/test/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryNativeReactiveDiscoveryClientTests.java @@ -0,0 +1,104 @@ +/* + * Copyright 2019-2019 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.cloud.cloudfoundry.discovery.reactive; + +import java.util.UUID; + +import org.cloudfoundry.operations.CloudFoundryOperations; +import org.cloudfoundry.operations.applications.ApplicationDetail; +import org.cloudfoundry.operations.applications.ApplicationSummary; +import org.cloudfoundry.operations.applications.Applications; +import org.cloudfoundry.operations.applications.InstanceDetail; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; +import reactor.util.function.Tuple2; +import reactor.util.function.Tuples; + +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.cloudfoundry.CloudFoundryService; +import org.springframework.cloud.cloudfoundry.discovery.CloudFoundryDiscoveryProperties; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * @author Tim Ysewyn + */ +@ExtendWith(MockitoExtension.class) +class CloudFoundryNativeReactiveDiscoveryClientTests { + + @Mock + private CloudFoundryOperations operations; + + @Mock + private CloudFoundryService svc; + + @Mock + private CloudFoundryDiscoveryProperties properties; + + @InjectMocks + private CloudFoundryNativeReactiveDiscoveryClient client; + + @Test + public void verifyDefaults() { + when(properties.getOrder()).thenReturn(0); + assertThat(client.description()) + .isEqualTo("CF Reactive Service Discovery Client"); + assertThat(client.getOrder()).isEqualTo(0); + } + + @Test + public void shouldReturnFluxOfServices() { + Applications apps = mock(Applications.class); + when(operations.applications()).thenReturn(apps); + ApplicationSummary summary = ApplicationSummary.builder() + .id(UUID.randomUUID().toString()).instances(1).memoryLimit(1024) + .requestedState("requestedState").diskQuota(1024).name("service") + .runningInstances(1).build(); + when(apps.list()).thenReturn(Flux.just(summary)); + Flux services = this.client.getServices(); + StepVerifier.create(services).expectNext("service").expectComplete().verify(); + } + + @Test + public void shouldReturnEmptyFluxForNonExistingService() { + when(svc.getApplicationInstances("service")).thenReturn(Flux.empty()); + Flux instances = this.client.getInstances("service"); + StepVerifier.create(instances).expectNextCount(0).expectComplete().verify(); + } + + @Test + public void shouldReturnFluxOfServiceInstances() { + ApplicationDetail applicationDetail = ApplicationDetail.builder() + .id(UUID.randomUUID().toString()).stack("stack").instances(1) + .memoryLimit(1024).requestedState("requestedState").diskQuota(1024) + .name("service").runningInstances(1).build(); + InstanceDetail instanceDetail = InstanceDetail.builder().index("0").build(); + Tuple2 instance = Tuples.of(applicationDetail, + instanceDetail); + when(this.svc.getApplicationInstances("service")).thenReturn(Flux.just(instance)); + Flux instances = this.client.getInstances("service"); + StepVerifier.create(instances).expectNextCount(1).expectComplete().verify(); + } + +} diff --git a/spring-cloud-cloudfoundry-discovery/src/test/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryReactiveDiscoveryClientConfigurationTests.java b/spring-cloud-cloudfoundry-discovery/src/test/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryReactiveDiscoveryClientConfigurationTests.java new file mode 100644 index 0000000..aeca87b --- /dev/null +++ b/spring-cloud-cloudfoundry-discovery/src/test/java/org/springframework/cloud/cloudfoundry/discovery/reactive/CloudFoundryReactiveDiscoveryClientConfigurationTests.java @@ -0,0 +1,216 @@ +/* + * Copyright 2019-2019 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.cloud.cloudfoundry.discovery.reactive; + +import org.cloudfoundry.operations.CloudFoundryOperations; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.cloud.client.ReactiveCommonsClientAutoConfiguration; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.discovery.ReactiveDiscoveryClient; +import org.springframework.cloud.client.discovery.health.reactive.ReactiveDiscoveryClientHealthIndicator; +import org.springframework.cloud.cloudfoundry.CloudFoundryService; +import org.springframework.context.annotation.Bean; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * @author Tim Ysewyn + */ +class CloudFoundryReactiveDiscoveryClientConfigurationTests { + + private ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(MockedCloudFoundryConfiguration.class, + CloudFoundryReactiveDiscoveryClientConfiguration.class)); + + @Test + public void shouldNotHaveDiscoveryClientsWhenDiscoveryDisabled() { + contextRunner.withPropertyValues("spring.cloud.discovery.enabled=false") + .run(context -> { + assertThat(context).doesNotHaveBean("cloudFoundryHeartbeatSender"); + assertThat(context).doesNotHaveBean(ReactiveDiscoveryClient.class); + assertThat(context).doesNotHaveBean( + ReactiveDiscoveryClientHealthIndicator.class); + }); + } + + @Test + public void shouldNotHaveDiscoveryClientsWhenReactiveDiscoveryDisabled() { + contextRunner.withPropertyValues("spring.cloud.discovery.reactive.enabled=false") + .run(context -> { + assertThat(context).doesNotHaveBean("cloudFoundryHeartbeatSender"); + assertThat(context).doesNotHaveBean(ReactiveDiscoveryClient.class); + assertThat(context).doesNotHaveBean( + ReactiveDiscoveryClientHealthIndicator.class); + }); + } + + @Test + public void shouldNotHaveDiscoveryClientsWhenCloudFoundryDiscoveryDisabled() { + contextRunner + .withPropertyValues("spring.cloud.cloudfoundry.discovery.enabled=false") + .run(context -> { + assertThat(context).doesNotHaveBean("cloudFoundryHeartbeatSender"); + assertThat(context).doesNotHaveBean(ReactiveDiscoveryClient.class); + assertThat(context).doesNotHaveBean( + ReactiveDiscoveryClientHealthIndicator.class); + }); + } + + @Test + public void shouldUseNativeDiscovery() { + contextRunner + .withConfiguration(AutoConfigurations + .of(ReactiveCommonsClientAutoConfiguration.class)) + .run(context -> { + assertThat(context) + .hasSingleBean(CloudFoundryReactiveHeartbeatSender.class); + assertThat(context).hasSingleBean(ReactiveDiscoveryClient.class); + assertThat(context).hasBean("nativeCloudFoundryDiscoveryClient"); + assertThat(context) + .hasSingleBean(ReactiveDiscoveryClientHealthIndicator.class); + }); + } + + @Test + public void shouldUseDnsDiscovery() { + contextRunner + .withConfiguration(AutoConfigurations + .of(ReactiveCommonsClientAutoConfiguration.class)) + .withPropertyValues("spring.cloud.cloudfoundry.discovery.use-dns=true", + "spring.cloud.cloudfoundry.discovery.use-container-ip=true") + .run(context -> { + assertThat(context) + .hasSingleBean(CloudFoundryReactiveHeartbeatSender.class); + assertThat(context).hasSingleBean(ReactiveDiscoveryClient.class); + assertThat(context).hasBean("dnsBasedReactiveDiscoveryClient"); + assertThat(context) + .hasSingleBean(ReactiveDiscoveryClientHealthIndicator.class); + }); + } + + @Test + public void shouldUseAppServiceDiscovery() { + contextRunner + .withConfiguration(AutoConfigurations + .of(ReactiveCommonsClientAutoConfiguration.class)) + .withPropertyValues("spring.cloud.cloudfoundry.discovery.use-dns=true", + "spring.cloud.cloudfoundry.discovery.use-container-ip=false") + .run(context -> { + assertThat(context) + .hasSingleBean(CloudFoundryReactiveHeartbeatSender.class); + assertThat(context).hasSingleBean(ReactiveDiscoveryClient.class); + assertThat(context).hasBean("appServiceReactiveDiscoveryClient"); + assertThat(context) + .hasSingleBean(ReactiveDiscoveryClientHealthIndicator.class); + }); + } + + @Test + public void shouldUseCustomServiceDiscovery() { + contextRunner + .withConfiguration(AutoConfigurations + .of(ReactiveCommonsClientAutoConfiguration.class)) + .withUserConfiguration( + CustomCloudFoundryReactiveDiscoveryClientConfiguration.class) + .run(context -> { + assertThat(context) + .hasSingleBean(CloudFoundryReactiveHeartbeatSender.class); + assertThat(context).getBeans(ReactiveDiscoveryClient.class) + .hasSize(2); + assertThat(context).hasBean("nativeCloudFoundryDiscoveryClient"); + assertThat(context) + .hasSingleBean(ReactiveDiscoveryClientHealthIndicator.class); + }); + } + + @Test + public void worksWithoutWebflux() { + contextRunner + .withClassLoader( + new FilteredClassLoader("org.springframework.web.reactive")) + .run(context -> { + assertThat(context) + .doesNotHaveBean(CloudFoundryReactiveHeartbeatSender.class); + assertThat(context).doesNotHaveBean(ReactiveDiscoveryClient.class); + assertThat(context).doesNotHaveBean( + ReactiveDiscoveryClientHealthIndicator.class); + }); + } + + @Test + public void worksWithoutActuator() { + contextRunner + .withClassLoader( + new FilteredClassLoader("org.springframework.boot.actuate")) + .run(context -> { + assertThat(context) + .hasSingleBean(CloudFoundryReactiveHeartbeatSender.class); + assertThat(context).hasSingleBean(ReactiveDiscoveryClient.class); + assertThat(context).doesNotHaveBean( + ReactiveDiscoveryClientHealthIndicator.class); + }); + } + + @TestConfiguration + static class MockedCloudFoundryConfiguration { + + @Bean + public CloudFoundryOperations mockedOperations() { + return mock(CloudFoundryOperations.class); + } + + @Bean + public CloudFoundryService mockedService() { + return mock(CloudFoundryService.class); + } + + } + + @TestConfiguration + static class CustomCloudFoundryReactiveDiscoveryClientConfiguration { + + @Bean + public ReactiveDiscoveryClient customDiscoveryClient() { + return new ReactiveDiscoveryClient() { + @Override + public String description() { + return "Custom Reactive Discovery Client"; + } + + @Override + public Flux getInstances(String serviceId) { + return Flux.empty(); + } + + @Override + public Flux getServices() { + return Flux.empty(); + } + }; + } + + } + +}