diff --git a/pom.xml b/pom.xml index 8c7b7b738..bb00e4721 100644 --- a/pom.xml +++ b/pom.xml @@ -134,6 +134,26 @@ spring-cloud-test-support ${spring-cloud-commons.version} + + org.testcontainers + testcontainers + ${testcontainers.version} + + + org.testcontainers + mockserver + ${testcontainers.version} + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + + + org.mock-server + mockserver-client-java + ${mockserverclient.version} + @@ -142,6 +162,8 @@ 3.1.7-SNAPSHOT 3.1.8-SNAPSHOT 3.1.8-SNAPSHOT + 1.17.6 + 5.15.0 diff --git a/spring-cloud-zookeeper-discovery/pom.xml b/spring-cloud-zookeeper-discovery/pom.xml index a60e5ecc2..66e26ca38 100644 --- a/spring-cloud-zookeeper-discovery/pom.xml +++ b/spring-cloud-zookeeper-discovery/pom.xml @@ -156,6 +156,26 @@ reactor-test test + + org.testcontainers + testcontainers + test + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + mockserver + test + + + org.mock-server + mockserver-client-java + test + diff --git a/spring-cloud-zookeeper-discovery/src/main/java/org/springframework/cloud/zookeeper/discovery/configclient/ZookeeperConfigServerBootstrapper.java b/spring-cloud-zookeeper-discovery/src/main/java/org/springframework/cloud/zookeeper/discovery/configclient/ZookeeperConfigServerBootstrapper.java index 5a67f7fca..5bdd11f4b 100644 --- a/spring-cloud-zookeeper-discovery/src/main/java/org/springframework/cloud/zookeeper/discovery/configclient/ZookeeperConfigServerBootstrapper.java +++ b/spring-cloud-zookeeper-discovery/src/main/java/org/springframework/cloud/zookeeper/discovery/configclient/ZookeeperConfigServerBootstrapper.java @@ -17,23 +17,31 @@ package org.springframework.cloud.zookeeper.discovery.configclient; import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; +import org.apache.commons.logging.Log; +import org.apache.curator.RetryPolicy; import org.apache.curator.framework.CuratorFramework; +import org.apache.curator.retry.ExponentialBackoffRetry; import org.apache.curator.x.discovery.ServiceDiscovery; import org.apache.curator.x.discovery.ServiceDiscoveryBuilder; import org.apache.curator.x.discovery.details.InstanceSerializer; import org.apache.curator.x.discovery.details.JsonInstanceSerializer; +import org.springframework.boot.BootstrapContext; import org.springframework.boot.BootstrapRegistry; import org.springframework.boot.BootstrapRegistryInitializer; import org.springframework.boot.context.properties.bind.BindHandler; import org.springframework.boot.context.properties.bind.Bindable; import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.commons.util.InetUtils; import org.springframework.cloud.commons.util.InetUtilsProperties; import org.springframework.cloud.config.client.ConfigClientProperties; import org.springframework.cloud.config.client.ConfigServerInstanceProvider; import org.springframework.cloud.zookeeper.CuratorFactory; +import org.springframework.cloud.zookeeper.ZookeeperProperties; import org.springframework.cloud.zookeeper.discovery.ConditionalOnZookeeperDiscoveryEnabled; import org.springframework.cloud.zookeeper.discovery.ZookeeperDiscoveryClient; import org.springframework.cloud.zookeeper.discovery.ZookeeperDiscoveryProperties; @@ -45,6 +53,12 @@ public class ZookeeperConfigServerBootstrapper implements BootstrapRegistryInitializer { + private static boolean isEnabled(Binder binder) { + return binder.bind(ConfigClientProperties.CONFIG_DISCOVERY_ENABLED, Boolean.class).orElse(false) && + binder.bind(ConditionalOnZookeeperDiscoveryEnabled.PROPERTY, Boolean.class).orElse(true) && + binder.bind("spring.cloud.discovery.enabled", Boolean.class).orElse(true); + } + @Override @SuppressWarnings("unchecked") public void initialize(BootstrapRegistry registry) { @@ -95,7 +109,7 @@ public void initialize(BootstrapRegistry registry) { } ServiceDiscovery serviceDiscovery = context.get(ServiceDiscovery.class); ZookeeperDependencies dependencies = binder.bind(ZookeeperDependencies.PREFIX, Bindable - .of(ZookeeperDependencies.class), getBindHandler(context)) + .of(ZookeeperDependencies.class), getBindHandler(context)) .orElseGet(ZookeeperDependencies::new); ZookeeperDiscoveryProperties discoveryProperties = context.get(ZookeeperDiscoveryProperties.class); @@ -103,12 +117,9 @@ public void initialize(BootstrapRegistry registry) { }); // create instance provider - registry.registerIfAbsent(ConfigServerInstanceProvider.Function.class, context -> { - if (!isEnabled(context.get(Binder.class))) { - return (id) -> Collections.emptyList(); - } - return context.get(ZookeeperDiscoveryClient.class)::getInstances; - }); + // We need to pass the lambda here so we do not create a new instance of ConfigServerInstanceProvider.Function + // which would result in a ClassNotFoundException when Spring Cloud Config is not on the classpath + registry.registerIfAbsent(ConfigServerInstanceProvider.Function.class, ZookeeperFunction::create); // promote beans to context registry.addCloseListener(event -> { @@ -124,10 +135,63 @@ private BindHandler getBindHandler(org.springframework.boot.BootstrapContext con return context.getOrElse(BindHandler.class, null); } - private boolean isEnabled(Binder binder) { - return binder.bind(ConfigClientProperties.CONFIG_DISCOVERY_ENABLED, Boolean.class).orElse(false) && - binder.bind(ConditionalOnZookeeperDiscoveryEnabled.PROPERTY, Boolean.class).orElse(true) && - binder.bind("spring.cloud.discovery.enabled", Boolean.class).orElse(true); + /* + * This Function is executed when loading config data. Because of this we cannot rely on the + * BootstrapContext because Boot has not finished loading all the configuration data so if we + * ask the BootstrapContext for configuration data it will not have it. The apply method in this function + * is passed the Binder and BindHandler from the config data context which has the configuration properties that + * have been loaded so far in the config data process. + * + * We will create many of the same beans in this function as we do above in the initializer above. We do both + * to maintain compatibility since we are promoting those beans to the main application context. + */ + static final class ZookeeperFunction implements ConfigServerInstanceProvider.Function { + + private final BootstrapContext context; + + private ZookeeperFunction(BootstrapContext context) { + this.context = context; + } + + static ZookeeperFunction create(BootstrapContext context) { + return new ZookeeperFunction(context); + } + + @Override + public List apply(String serviceId) { + return apply(serviceId, null, null, null); + } + + @Override + public List apply(String serviceId, Binder binder, BindHandler bindHandler, Log log) { + if (binder == null || !isEnabled(binder)) { + return Collections.emptyList(); + } + + ZookeeperProperties properties = binder.bind(ZookeeperProperties.PREFIX, Bindable.of(ZookeeperProperties.class)) + .orElse(new ZookeeperProperties()); + RetryPolicy retryPolicy = new ExponentialBackoffRetry(properties.getBaseSleepTimeMs(), properties.getMaxRetries(), + properties.getMaxSleepMs()); + try { + CuratorFramework curatorFramework = CuratorFactory.curatorFramework(properties, retryPolicy, Stream::of, + () -> null, () -> null); + InstanceSerializer serializer = new JsonInstanceSerializer<>(ZookeeperInstance.class); + ZookeeperDiscoveryProperties discoveryProperties = binder.bind(ZookeeperDiscoveryProperties.PREFIX, Bindable + .of(ZookeeperDiscoveryProperties.class), bindHandler) + .orElseGet(() -> new ZookeeperDiscoveryProperties(new InetUtils(new InetUtilsProperties()))); + DefaultServiceDiscoveryCustomizer customizer = new DefaultServiceDiscoveryCustomizer(curatorFramework, discoveryProperties, serializer); + ServiceDiscovery serviceDiscovery = customizer.customize(ServiceDiscoveryBuilder.builder(ZookeeperInstance.class)); + ZookeeperDependencies dependencies = binder.bind(ZookeeperDependencies.PREFIX, Bindable + .of(ZookeeperDependencies.class), bindHandler) + .orElseGet(ZookeeperDependencies::new); + return new ZookeeperDiscoveryClient(serviceDiscovery, dependencies, discoveryProperties).getInstances(serviceId); + } + catch (Exception e) { + log.warn("Error fetching config server instance from Zookeeper", e); + return Collections.emptyList(); + } + + } } } diff --git a/spring-cloud-zookeeper-discovery/src/test/java/org/springframework/cloud/zookeeper/discovery/configclient/ZookeeperConfigServerBootstrapperTests.java b/spring-cloud-zookeeper-discovery/src/test/java/org/springframework/cloud/zookeeper/discovery/configclient/ZookeeperConfigServerBootstrapperTests.java index a66ca956b..d1dd19ba8 100644 --- a/spring-cloud-zookeeper-discovery/src/test/java/org/springframework/cloud/zookeeper/discovery/configclient/ZookeeperConfigServerBootstrapperTests.java +++ b/spring-cloud-zookeeper-discovery/src/test/java/org/springframework/cloud/zookeeper/discovery/configclient/ZookeeperConfigServerBootstrapperTests.java @@ -18,6 +18,7 @@ import java.util.concurrent.atomic.AtomicReference; +import org.apache.commons.logging.Log; import org.apache.curator.framework.CuratorFramework; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -31,6 +32,7 @@ import org.springframework.boot.context.properties.bind.BindContext; import org.springframework.boot.context.properties.bind.BindHandler; import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.context.properties.source.ConfigurationPropertyName; import org.springframework.cloud.config.client.ConfigServerInstanceProvider; import org.springframework.cloud.zookeeper.discovery.ZookeeperDiscoveryClient; @@ -39,6 +41,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; public class ZookeeperConfigServerBootstrapperTests { @@ -59,7 +62,8 @@ public void notEnabledReturnsEmptyList() { .addBootstrapRegistryInitializer(registry -> registry.addCloseListener(event -> { ConfigServerInstanceProvider.Function providerFn = event.getBootstrapContext() .get(ConfigServerInstanceProvider.Function.class); - assertThat(providerFn.apply("id")).as("ConfigServerInstanceProvider.Function should return empty list") + Log log = mock(Log.class); + assertThat(providerFn.apply("id", event.getBootstrapContext().get(Binder.class), event.getBootstrapContext().get(BindHandler.class), log)).as("ConfigServerInstanceProvider.Function should return empty list") .isEmpty(); })).run(); CuratorFramework curatorFramework = context.getBean("curatorFramework", CuratorFramework.class); @@ -80,7 +84,8 @@ public void zookeeperDiscoveryClientDisabledReturnsEmptyList() { .addBootstrapRegistryInitializer(registry -> registry.addCloseListener(event -> { ConfigServerInstanceProvider.Function providerFn = event.getBootstrapContext() .get(ConfigServerInstanceProvider.Function.class); - assertThat(providerFn.apply("id")).as("ConfigServerInstanceProvider.Function should return empty list") + Log log = mock(Log.class); + assertThat(providerFn.apply("id", event.getBootstrapContext().get(Binder.class), event.getBootstrapContext().get(BindHandler.class), log)).as("ConfigServerInstanceProvider.Function should return empty list") .isEmpty(); })).run(); CuratorFramework curatorFramework = context.getBean("curatorFramework", CuratorFramework.class); @@ -101,7 +106,7 @@ public void discoveryClientDisabledReturnsEmptyList() { .addBootstrapRegistryInitializer(registry -> registry.addCloseListener(event -> { ConfigServerInstanceProvider.Function providerFn = event.getBootstrapContext() .get(ConfigServerInstanceProvider.Function.class); - assertThat(providerFn.apply("id")).as("ConfigServerInstanceProvider.Function should return empty list") + assertThat(providerFn.apply("id", event.getBootstrapContext().get(Binder.class), event.getBootstrapContext().get(BindHandler.class), mock(Log.class))).as("ConfigServerInstanceProvider.Function should return empty list") .isEmpty(); })).run(); CuratorFramework curatorFramework = context.getBean("curatorFramework", CuratorFramework.class); @@ -124,7 +129,7 @@ public void enabledAddsInstanceProviderFn() { .addBootstrapRegistryInitializer(registry -> registry.addCloseListener(event -> { ConfigServerInstanceProvider.Function providerFn = event.getBootstrapContext() .get(ConfigServerInstanceProvider.Function.class); - assertThat(providerFn.apply("id")).as("Should return empty list.") + assertThat(providerFn.apply("id", event.getBootstrapContext().get(Binder.class), event.getBootstrapContext().get(BindHandler.class), mock(Log.class))).as("Should return empty list.") .isNotNull(); bootstrapDiscoveryClient.set(event.getBootstrapContext().get(ZookeeperDiscoveryClient.class)); })).run(); diff --git a/spring-cloud-zookeeper-discovery/src/test/java/org/springframework/cloud/zookeeper/discovery/configclient/ZookeeperConfigServerBootstrapperTestsIT.java b/spring-cloud-zookeeper-discovery/src/test/java/org/springframework/cloud/zookeeper/discovery/configclient/ZookeeperConfigServerBootstrapperTestsIT.java new file mode 100644 index 000000000..2bd6ed462 --- /dev/null +++ b/spring-cloud-zookeeper-discovery/src/test/java/org/springframework/cloud/zookeeper/discovery/configclient/ZookeeperConfigServerBootstrapperTestsIT.java @@ -0,0 +1,147 @@ +/* + * Copyright 2015-2022 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.zookeeper.discovery.configclient; + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.curator.framework.CuratorFramework; +import org.apache.curator.framework.CuratorFrameworkFactory; +import org.apache.curator.retry.RetryOneTime; +import org.apache.curator.x.discovery.ServiceDiscovery; +import org.apache.curator.x.discovery.ServiceDiscoveryBuilder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockserver.client.MockServerClient; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.MockServerContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.config.environment.Environment; +import org.springframework.cloud.config.environment.PropertySource; +import org.springframework.cloud.zookeeper.ZookeeperProperties; +import org.springframework.cloud.zookeeper.discovery.ZookeeperDiscoveryProperties; +import org.springframework.cloud.zookeeper.discovery.ZookeeperInstance; +import org.springframework.cloud.zookeeper.serviceregistry.ServiceInstanceRegistration; +import org.springframework.context.ConfigurableApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; + +/** + * @author Ryan Baxter + */ +@Testcontainers +class ZookeeperConfigServerBootstrapperTestsIT { + private static final int ZOOKEEPER_PORT = 2181; + + public static final DockerImageName MOCKSERVER_IMAGE = DockerImageName.parse("mockserver/mockserver") + .withTag("mockserver-" + MockServerClient.class.getPackage().getImplementationVersion()); + + @Container + private static final GenericContainer zookeeper = new GenericContainer<>("zookeeper:3.8.0") + .withExposedPorts(ZOOKEEPER_PORT); + + @Container + static MockServerContainer mockServer = new MockServerContainer(MOCKSERVER_IMAGE); + + private ConfigurableApplicationContext context; + + private ServiceDiscovery serviceDiscovery; + + private CuratorFramework curatorFramework; + + @BeforeEach + void before() { + curatorFramework = CuratorFrameworkFactory.builder().connectString(zookeeper.getHost() + ":" + zookeeper.getMappedPort(ZOOKEEPER_PORT)) + .retryPolicy(new RetryOneTime(100)).build(); + + try { + curatorFramework.start(); + serviceDiscovery = ServiceDiscoveryBuilder.builder(ZookeeperInstance.class).client(curatorFramework).basePath("/services") + .build(); + serviceDiscovery.start(); + serviceDiscovery.registerService(ServiceInstanceRegistration.builder().id("zookeeper-configserver").name("zookeeper-configserver") + .address(mockServer.getHost()).port(mockServer.getServerPort()).uriSpec(ZookeeperDiscoveryProperties.DEFAULT_URI_SPEC).build().getServiceInstance()); + } + catch (Exception e) { + throw new RuntimeException(e); + } + } + + @AfterEach + void after() throws IOException { + this.context.close(); + this.serviceDiscovery.close(); + this.curatorFramework.close(); + } + + @Test + public void contextLoads() throws JsonProcessingException { + Environment environment = new Environment("test", "default"); + Map properties = new HashMap<>(); + properties.put("hello", "world"); + PropertySource p = new PropertySource("p1", properties); + environment.add(p); + ObjectMapper objectMapper = new ObjectMapper(); + try (MockServerClient mockServerClient = new MockServerClient(mockServer.getHost(), + mockServer.getMappedPort(MockServerContainer.PORT))) { + mockServerClient.when(request().withPath("/application/default")) + .respond(response().withBody(objectMapper.writeValueAsString(environment)) + .withHeader("content-type", "application/json")); + this.context = setup().run(); + assertThat(this.context.getEnvironment().getProperty("hello")).isEqualTo("world"); + } + + } + + SpringApplicationBuilder setup(String... env) { + return new SpringApplicationBuilder(TestConfig.class) + .properties(addDefaultEnv(env)); + } + + private String[] addDefaultEnv(String[] env) { + Set set = new LinkedHashSet<>(); + if (env != null && env.length > 0) { + set.addAll(Arrays.asList(env)); + } + set.add("spring.config.import=classpath:bootstrapper.yaml"); + set.add("spring.cloud.config.enabled=true"); + set.add("spring.cloud.service-registry.auto-registration.enabled=false"); + set.add(ZookeeperProperties.PREFIX + ".connectString=" + zookeeper.getHost() + ":" + zookeeper.getMappedPort(ZOOKEEPER_PORT)); + return set.toArray(new String[0]); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + static class TestConfig { + + } +} diff --git a/spring-cloud-zookeeper-discovery/src/test/resources/bootstrapper.yaml b/spring-cloud-zookeeper-discovery/src/test/resources/bootstrapper.yaml new file mode 100644 index 000000000..7cde1d1b5 --- /dev/null +++ b/spring-cloud-zookeeper-discovery/src/test/resources/bootstrapper.yaml @@ -0,0 +1,8 @@ +spring: + config: + import: "optional:configserver:" + cloud: + config: + discovery: + service-id: zookeeper-configserver + enabled: true