Merge branch '3.0.x'

This commit is contained in:
Ryan Baxter
2023-12-21 14:30:14 -05:00
26 changed files with 1185 additions and 41 deletions

View File

@@ -222,31 +222,62 @@ NOTE: `spring.application.name` has no effect as far as the name registered for
'''
Spring Cloud Kubernetes can also watch the Kubernetes service catalog for changes and update the
`DiscoveryClient` implementation accordingly. By "watch" we mean that we will publish a heartbeat event every `spring.cloud.kubernetes.discovery.catalog-services-watch-delay`
milliseconds (by default it is `30000`). The heartbeat event will contain the target references (and their namespaces of the addresses of all endpoints
Spring Cloud Kubernetes can also watch the Kubernetes service catalog for changes and update the `DiscoveryClient` implementation accordingly. In order to enable this functionality you need to add
`@EnableScheduling` on a configuration class in your application. By "watch", we mean that we will publish a heartbeat event every `spring.cloud.kubernetes.discovery.catalog-services-watch-delay`
milliseconds (by default it is `30000`). For the http discovery server this must be an environment variable set in deployment yaml:
----
containers:
- name: discovery-server
image: springcloud/spring-cloud-kubernetes-discoveryserver:3.0.5-SNAPSHOT
env:
- name: SPRING_CLOUD_KUBERNETES_DISCOVERY_CATALOGSERVICESWATCHDELAY
value: 3000
----
The heartbeat event will contain the target references (and their namespaces of the addresses of all endpoints
(for the exact details of what will get returned you can take a look inside `KubernetesCatalogWatch`). This is an implementation detail, and listeners of the heartbeat event
should not rely on the details. Instead, they should see if there are differences between two subsequent heartbeats via `equals` method. We will take care to return a correct implementation that adheres to the equals contract.
The endpoints will be queried in either :
- all namespaces (enabled via `spring.cloud.kubernetes.discovery.all-namespaces=true`)
- `all-namespaces` (enabled via `spring.cloud.kubernetes.discovery.all-namespaces=true`)
- specific namespaces (enabled via `spring.cloud.kubernetes.discovery.namespaces`), for example:
- `selective namespaces` (enabled via `spring.cloud.kubernetes.discovery.namespaces`), for example:
- `one namespace` via xref:property-source-config.adoc#namespace-resolution[Namespace Resolution] if the above two paths are not taken.
NOTE: If, for any reasons, you want to disable catalog watcher, you need to set `spring.cloud.kubernetes.discovery.catalog-services-watch.enabled=false`. For the http discovery server, this needs to be an environment variable set in deployment for example:
[source]
----
spring:
cloud:
kubernetes:
discovery:
namespaces:
- namespace-a
- namespace-b
SPRING_CLOUD_KUBERNETES_DISCOVERY_CATALOGSERVICESWATCH_ENABLED=FALSE
----
- we will use: xref:property-source-config.adoc#namespace-resolution[Namespace Resolution] if the above two paths are not taken.
The functionality of catalog watch works for all 3 discovery clients that we support, with some caveats that you need to be aware of in case of the http client.
In order to enable this functionality you need to add
`@EnableScheduling` on a configuration class in your application.
- The first is that this functionality is disabled by default, and it needs to be enabled in two places:
* in discovery server via an environment variable in the deployment manifest, for example:
+
----
containers:
- name: discovery-server
image: springcloud/spring-cloud-kubernetes-discoveryserver:3.0.5-SNAPSHOT
env:
- name: SPRING_CLOUD_KUBERNETES_HTTP_DISCOVERY_CATALOG_WATCHER_ENABLED
value: "TRUE"
----
+
* in discovery client, via a property in your `application.properties` for example:
+
----
spring.cloud.kubernetes.http.discovery.catalog.watcher.enabled=true
----
+
- The second point is that this is only supported since version `3.0.6` and upwards.
- Since http discovery has _two_ components : server and client, we strongly recommend to align versions between them, otherwise things might not work.
- If you decide to disable catalog watcher, you need to disable it in both server and client.
By default, we use the `Endpoints`(see https://kubernetes.io/docs/concepts/services-networking/service/#endpoints) API to find out the current state of services. There is another way though, via `EndpointSlices` (https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/). Such support can be enabled via a property: `spring.cloud.kubernetes.discovery.use-endpoint-slices=true` (by default it is `false`). Of course, your cluster has to support it also. As a matter of fact, if you enable this property, but your cluster does not support it, we will fail starting the application. If you decide to enable such support, you also need proper Role/ClusterRole set-up. For example:

View File

@@ -36,6 +36,7 @@ import org.springframework.context.ApplicationEventPublisherAware;
import org.springframework.core.log.LogAccessor;
import org.springframework.scheduling.annotation.Scheduled;
import static org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryConstants.CATALOG_WATCH_PROPERTY_WITH_DEFAULT_VALUE;
import static org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryConstants.DISCOVERY_GROUP;
import static org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryConstants.DISCOVERY_VERSION;
import static org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryConstants.ENDPOINT_SLICE;
@@ -67,7 +68,7 @@ class KubernetesCatalogWatch implements ApplicationEventPublisherAware {
this.publisher = publisher;
}
@Scheduled(fixedDelayString = "${spring.cloud.kubernetes.discovery.catalogServicesWatchDelay:30000}")
@Scheduled(fixedDelayString = "${" + CATALOG_WATCH_PROPERTY_WITH_DEFAULT_VALUE + "}")
void catalogServicesWatch() {
try {

View File

@@ -0,0 +1,41 @@
/*
* Copyright 2019-2023 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.kubernetes.commons.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 for:
* <code>spring.cloud.kubernetes.http.discovery.client.catalog.watcher.enabled</code>.
*
* @author wind57
*/
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@ConditionalOnProperty(value = "spring.cloud.kubernetes.http.discovery.catalog.watcher.enabled", matchIfMissing = false)
public @interface ConditionalOnHttpDiscoveryCatalogWatcherEnabled {
}

View File

@@ -82,4 +82,20 @@ public final class KubernetesDiscoveryConstants {
*/
public static final String SECURED = "secured";
/**
* catalog watch delay property name.
*/
public static final String CATALOG_WATCH_PROPERTY_NAME = "spring.cloud.kubernetes.discovery.catalogServicesWatchDelay";
/**
* default delay for the configuration watcher.
*/
public static final String CATALOG_WATCHER_DEFAULT_DELAY = "30000";
/**
* catalog watch delay property name with default value.
*/
public static final String CATALOG_WATCH_PROPERTY_WITH_DEFAULT_VALUE = CATALOG_WATCH_PROPERTY_NAME + ":"
+ CATALOG_WATCHER_DEFAULT_DELAY;
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2013-2023 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.kubernetes.discoveryserver;
import java.util.List;
import reactor.core.publisher.Mono;
import org.springframework.cloud.kubernetes.commons.discovery.ConditionalOnHttpDiscoveryCatalogWatcherEnabled;
import org.springframework.cloud.kubernetes.commons.discovery.ConditionalOnKubernetesCatalogEnabled;
import org.springframework.cloud.kubernetes.commons.discovery.EndpointNameAndNamespace;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author wind57
*/
@RestController
@ConditionalOnKubernetesCatalogEnabled
@ConditionalOnHttpDiscoveryCatalogWatcherEnabled
class DiscoveryCatalogWatcherController {
private final HeartBeatListener heartBeatListener;
DiscoveryCatalogWatcherController(HeartBeatListener heartBeatListener) {
this.heartBeatListener = heartBeatListener;
}
@GetMapping("/state")
Mono<List<EndpointNameAndNamespace>> state() {
return Mono.defer(() -> Mono.just(heartBeatListener.lastState().get()));
}
}

View File

@@ -18,11 +18,13 @@ package org.springframework.cloud.kubernetes.discoveryserver;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* @author Ryan Baxter
*/
@SpringBootApplication
@EnableScheduling
public class DiscoveryServerApplication {
public static void main(String[] args) {

View File

@@ -0,0 +1,73 @@
/*
* Copyright 2013-2023 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.kubernetes.discoveryserver;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.commons.logging.LogFactory;
import org.springframework.cloud.client.discovery.event.HeartbeatEvent;
import org.springframework.cloud.kubernetes.commons.discovery.ConditionalOnHttpDiscoveryCatalogWatcherEnabled;
import org.springframework.cloud.kubernetes.commons.discovery.ConditionalOnKubernetesCatalogEnabled;
import org.springframework.cloud.kubernetes.commons.discovery.EndpointNameAndNamespace;
import org.springframework.context.ApplicationListener;
import org.springframework.core.env.Environment;
import org.springframework.core.log.LogAccessor;
import org.springframework.stereotype.Component;
import static org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryConstants.CATALOG_WATCHER_DEFAULT_DELAY;
import static org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryConstants.CATALOG_WATCH_PROPERTY_NAME;
/**
* Listener for a HeartbeatEvent that comes from KubernetesCatalogWatch.
*
* @author wind57
*/
@Component
@ConditionalOnKubernetesCatalogEnabled
@ConditionalOnHttpDiscoveryCatalogWatcherEnabled
class HeartBeatListener implements ApplicationListener<HeartbeatEvent> {
private static final LogAccessor LOG = new LogAccessor(LogFactory.getLog(HeartBeatListener.class));
private final AtomicReference<List<EndpointNameAndNamespace>> lastState = new AtomicReference<>(List.of());
HeartBeatListener(Environment environment) {
String watchDelay = environment.getProperty(CATALOG_WATCH_PROPERTY_NAME);
if (watchDelay != null) {
LOG.debug("using delay : " + watchDelay);
}
else {
LOG.debug("using default watch delay : " + CATALOG_WATCHER_DEFAULT_DELAY);
}
}
@Override
@SuppressWarnings("unchecked")
public void onApplicationEvent(HeartbeatEvent event) {
LOG.debug(() -> "received heartbeat event");
List<EndpointNameAndNamespace> state = (List<EndpointNameAndNamespace>) event.getValue();
LOG.debug(() -> "state received : " + state);
lastState.set(state);
}
AtomicReference<List<EndpointNameAndNamespace>> lastState() {
return lastState;
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2013-2023 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.kubernetes.discoveryserver;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import reactor.test.StepVerifier;
import org.springframework.cloud.kubernetes.commons.discovery.EndpointNameAndNamespace;
/**
* @author wind57
*/
class DiscoveryCatalogWatcherControllerTests {
private final HeartBeatListener heartBeatListener = Mockito.mock(HeartBeatListener.class);
@Test
void test() {
Mockito.when(heartBeatListener.lastState())
.thenReturn(new AtomicReference<>(List.of(new EndpointNameAndNamespace("one", "two"))));
DiscoveryCatalogWatcherController catalogWatcherController = new DiscoveryCatalogWatcherController(
heartBeatListener);
StepVerifier.create(catalogWatcherController.state())
.expectNext(List.of(new EndpointNameAndNamespace("one", "two"))).verifyComplete();
}
}

View File

@@ -0,0 +1,141 @@
/*
* Copyright 2013-2023 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.kubernetes.discoveryserver;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.cloud.kubernetes.client.discovery.reactive.KubernetesInformerReactiveDiscoveryClient;
import org.springframework.context.annotation.Bean;
/**
* @author wind57
*/
class DiscoveryServerApplicationContextTests {
@Nested
@SpringBootTest(classes = TestConfig.class,
properties = "spring.cloud.kubernetes.http.discovery.catalog.watcher.enabled=true")
class BothControllersPresent {
@Autowired
private ObjectProvider<DiscoveryServerController> discoveryServerController;
@Autowired
private ObjectProvider<DiscoveryCatalogWatcherController> discoveryCatalogWatcherController;
@Autowired
private ObjectProvider<HeartBeatListener> heartBeatListener;
@Test
void test() {
Assertions.assertNotNull(discoveryServerController.getIfAvailable());
Assertions.assertNotNull(discoveryCatalogWatcherController.getIfAvailable());
Assertions.assertNotNull(heartBeatListener.getIfAvailable());
}
}
@Nested
@SpringBootTest(classes = TestConfig.class,
properties = { "spring.cloud.kubernetes.discovery.catalog-services-watch.enabled=false",
"spring.cloud.kubernetes.http.discovery.catalog.watcher.enabled=true" })
class CatalogControllerNotPresentOne {
@Autowired
private ObjectProvider<DiscoveryServerController> discoveryServerController;
@Autowired
private ObjectProvider<DiscoveryCatalogWatcherController> discoveryCatalogWatcherController;
@Autowired
private ObjectProvider<HeartBeatListener> heartBeatListener;
@Test
void test() {
Assertions.assertNotNull(discoveryServerController.getIfAvailable());
Assertions.assertNull(discoveryCatalogWatcherController.getIfAvailable());
Assertions.assertNull(heartBeatListener.getIfAvailable());
}
}
@Nested
@SpringBootTest(classes = TestConfig.class,
properties = { "spring.cloud.kubernetes.discovery.catalog-services-watch.enabled=true",
"spring.cloud.kubernetes.http.discovery.catalog.watcher.enabled=false" })
class CatalogControllerNotPresentTwo {
@Autowired
private ObjectProvider<DiscoveryServerController> discoveryServerController;
@Autowired
private ObjectProvider<DiscoveryCatalogWatcherController> discoveryCatalogWatcherController;
@Autowired
private ObjectProvider<HeartBeatListener> heartBeatListener;
@Test
void test() {
Assertions.assertNotNull(discoveryServerController.getIfAvailable());
Assertions.assertNull(discoveryCatalogWatcherController.getIfAvailable());
Assertions.assertNull(heartBeatListener.getIfAvailable());
}
}
@Nested
@SpringBootTest(classes = TestConfig.class,
properties = { "spring.cloud.kubernetes.discovery.catalog-services-watch.enabled=false",
"spring.cloud.kubernetes.http.discovery.catalog.watcher.enabled=false" })
class CatalogControllerNotPresentThree {
@Autowired
private ObjectProvider<DiscoveryServerController> discoveryServerController;
@Autowired
private ObjectProvider<DiscoveryCatalogWatcherController> discoveryCatalogWatcherController;
@Autowired
private ObjectProvider<HeartBeatListener> heartBeatListener;
@Test
void test() {
Assertions.assertNotNull(discoveryServerController.getIfAvailable());
Assertions.assertNull(discoveryCatalogWatcherController.getIfAvailable());
Assertions.assertNull(heartBeatListener.getIfAvailable());
}
}
@TestConfiguration
static class TestConfig {
@Bean
KubernetesInformerReactiveDiscoveryClient discoveryClient() {
return Mockito.mock(KubernetesInformerReactiveDiscoveryClient.class);
}
}
}

View File

@@ -0,0 +1,99 @@
/*
* Copyright 2013-2023 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.kubernetes.discoveryserver;
import java.util.List;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.cloud.client.discovery.event.HeartbeatEvent;
import org.springframework.cloud.kubernetes.client.discovery.reactive.KubernetesInformerReactiveDiscoveryClient;
import org.springframework.cloud.kubernetes.commons.discovery.EndpointNameAndNamespace;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationEventPublisherAware;
import org.springframework.context.annotation.Bean;
import org.springframework.test.web.reactive.server.WebTestClient;
/**
* @author wind57
*/
@SpringBootTest(classes = HeartbeatTest.TestConfig.class,
properties = "spring.cloud.kubernetes.http.discovery.catalog.watcher.enabled=true")
@AutoConfigureWebTestClient
class HeartbeatTest {
@Autowired
private WebTestClient client;
@Autowired
private ApplicationContext context;
@Test
void testHeartbeat() {
Assertions.assertTrue(true);
client.get().uri("/state").exchange().expectStatus().is2xxSuccessful().expectBody().json("[]");
context.getBean(HeartbeatPublisher.class).publishEvent();
client.get().uri("/state").exchange().expectStatus().is2xxSuccessful().expectBody().json("""
[
{
"endpointName":"endpoint-name",
"namespace":"namespaceA"
}
]
""");
}
@TestConfiguration
static class TestConfig {
@Bean
KubernetesInformerReactiveDiscoveryClient client() {
return Mockito.mock(KubernetesInformerReactiveDiscoveryClient.class);
}
@Bean
HeartbeatPublisher heartbeatPublisher() {
return new HeartbeatPublisher();
}
}
static class HeartbeatPublisher implements ApplicationEventPublisherAware {
private ApplicationEventPublisher publisher;
@Override
public void setApplicationEventPublisher(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
void publishEvent() {
publisher.publishEvent(
new HeartbeatEvent("test", List.of(new EndpointNameAndNamespace("endpoint-name", "namespaceA"))));
}
}
}

View File

@@ -79,7 +79,7 @@ class ConfigServerBootstrapper extends KubernetesConfigServerBootstrapper {
.orElseGet(() -> KubernetesDiscoveryProperties.DEFAULT);
KubernetesDiscoveryClientBlockingAutoConfiguration autoConfiguration = new KubernetesDiscoveryClientBlockingAutoConfiguration();
DiscoveryClient discoveryClient = autoConfiguration
.kubernetesDiscoveryClient(autoConfiguration.restTemplate(), kubernetesDiscoveryProperties);
.kubernetesDiscoveryClient(autoConfiguration.restTemplateBuilder(), kubernetesDiscoveryProperties);
return discoveryClient::getInstances;
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright 2013-2023 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.kubernetes.discovery;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.commons.logging.LogFactory;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.cloud.client.discovery.event.HeartbeatEvent;
import org.springframework.cloud.kubernetes.commons.discovery.EndpointNameAndNamespace;
import org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryProperties;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationEventPublisherAware;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.log.LogAccessor;
import org.springframework.http.HttpMethod;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.web.client.RestTemplate;
import static org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryConstants.CATALOG_WATCH_PROPERTY_WITH_DEFAULT_VALUE;
/**
* @author wind57
*/
final class KubernetesCatalogWatch implements ApplicationEventPublisherAware {
private static final ParameterizedTypeReference<List<EndpointNameAndNamespace>> TYPE = new ParameterizedTypeReference<>() {
};
private static final LogAccessor LOG = new LogAccessor(LogFactory.getLog(KubernetesCatalogWatch.class));
private final AtomicReference<List<EndpointNameAndNamespace>> catalogState = new AtomicReference<>(List.of());
private final RestTemplate restTemplate;
private ApplicationEventPublisher publisher;
KubernetesCatalogWatch(RestTemplateBuilder builder, KubernetesDiscoveryProperties properties) {
this.restTemplate = builder.rootUri(properties.discoveryServerUrl()).build();
}
@Override
public void setApplicationEventPublisher(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
@Scheduled(fixedDelayString = "${" + CATALOG_WATCH_PROPERTY_WITH_DEFAULT_VALUE + "}")
public void catalogServicesWatch() {
try {
List<EndpointNameAndNamespace> currentState = restTemplate.exchange("/state", HttpMethod.GET, null, TYPE)
.getBody();
if (!catalogState.get().equals(currentState)) {
LOG.debug(() -> "Received update from kubernetes discovery http client: " + currentState);
publisher.publishEvent(new HeartbeatEvent(this, currentState));
}
catalogState.set(currentState);
}
catch (Exception e) {
LOG.error(e, () -> "Error watching Kubernetes Services");
}
}
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright 2013-2023 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.kubernetes.discovery;
import org.apache.commons.logging.LogFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnCloudPlatform;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.cloud.CloudPlatform;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.cloud.client.ConditionalOnDiscoveryEnabled;
import org.springframework.cloud.kubernetes.commons.discovery.ConditionalOnHttpDiscoveryCatalogWatcherEnabled;
import org.springframework.cloud.kubernetes.commons.discovery.ConditionalOnKubernetesCatalogEnabled;
import org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.core.log.LogAccessor;
import static org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryConstants.CATALOG_WATCHER_DEFAULT_DELAY;
import static org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryConstants.CATALOG_WATCH_PROPERTY_NAME;
/**
* @author wind57
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnDiscoveryEnabled
@ConditionalOnKubernetesCatalogEnabled
@ConditionalOnHttpDiscoveryCatalogWatcherEnabled
@ConditionalOnCloudPlatform(CloudPlatform.KUBERNETES)
@EnableConfigurationProperties(KubernetesDiscoveryProperties.class)
class KubernetesCatalogWatchAutoConfiguration {
private static final LogAccessor LOG = new LogAccessor(
LogFactory.getLog(KubernetesCatalogWatchAutoConfiguration.class));
// this has to be a RestTemplateBuilder and not a WebClientBuilder, otherwise
// we need the webflux dependency, and it might leak into client's dependencies
// which is not always desirable.
@Bean
@ConditionalOnMissingBean
RestTemplateBuilder restTemplateBuilder() {
return new RestTemplateBuilder();
}
@Bean
@ConditionalOnMissingBean
KubernetesCatalogWatch kubernetesCatalogWatch(RestTemplateBuilder builder, KubernetesDiscoveryProperties properties,
Environment environment) {
String watchDelay = environment.getProperty(CATALOG_WATCH_PROPERTY_NAME);
if (watchDelay != null) {
LOG.debug("using delay : " + watchDelay);
}
else {
LOG.debug("using default watch delay : " + CATALOG_WATCHER_DEFAULT_DELAY);
}
return new KubernetesCatalogWatch(builder, properties);
}
}

View File

@@ -28,7 +28,6 @@ import org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscover
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
/**
* @author wind57
@@ -41,15 +40,15 @@ class KubernetesDiscoveryClientBlockingAutoConfiguration {
@Bean
@ConditionalOnMissingBean
RestTemplate restTemplate() {
return new RestTemplateBuilder().build();
RestTemplateBuilder restTemplateBuilder() {
return new RestTemplateBuilder();
}
@Bean
@ConditionalOnMissingBean
KubernetesDiscoveryClient kubernetesDiscoveryClient(RestTemplate restTemplate,
KubernetesDiscoveryClient kubernetesDiscoveryClient(RestTemplateBuilder restTemplateBuilder,
KubernetesDiscoveryProperties properties) {
return new KubernetesDiscoveryClient(restTemplate, properties);
return new KubernetesDiscoveryClient(restTemplateBuilder.build(), properties);
}
@Bean

View File

@@ -40,14 +40,14 @@ public class KubernetesReactiveDiscoveryClient implements ReactiveDiscoveryClien
if (!StringUtils.hasText(properties.getDiscoveryServerUrl())) {
throw new DiscoveryServerUrlInvalidException();
}
this.webClient = webClientBuilder.baseUrl(properties.getDiscoveryServerUrl()).build();
webClient = webClientBuilder.baseUrl(properties.getDiscoveryServerUrl()).build();
}
KubernetesReactiveDiscoveryClient(WebClient.Builder webClientBuilder, KubernetesDiscoveryProperties properties) {
if (!StringUtils.hasText(properties.discoveryServerUrl())) {
throw new DiscoveryServerUrlInvalidException();
}
this.webClient = webClientBuilder.baseUrl(properties.discoveryServerUrl()).build();
webClient = webClientBuilder.baseUrl(properties.discoveryServerUrl()).build();
}
@Override

View File

@@ -1,2 +1,3 @@
org.springframework.cloud.kubernetes.discovery.KubernetesDiscoveryClientBlockingAutoConfiguration
org.springframework.cloud.kubernetes.discovery.KubernetesDiscoveryClientReactiveAutoConfiguration
org.springframework.cloud.kubernetes.discovery.KubernetesCatalogWatchAutoConfiguration

View File

@@ -0,0 +1,133 @@
/*
* Copyright 2013-2023 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.kubernetes.discovery;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author wind57
*/
class KubernetesCatalogWatchAutoConfigurationTests {
private ApplicationContextRunner applicationContextRunner;
@Test
void discoveryEnabledDefault() {
setup("spring.main.cloud-platform=KUBERNETES", "spring.cloud.config.enabled=false",
"spring.cloud.kubernetes.discovery.discovery-server-url=example.com",
"spring.cloud.kubernetes.http.discovery.catalog.watcher.enabled=true");
applicationContextRunner.run(context -> assertThat(context).hasSingleBean(KubernetesCatalogWatch.class));
}
@Test
void discoveryEnabled() {
setup("spring.main.cloud-platform=KUBERNETES", "spring.cloud.config.enabled=false",
"spring.cloud.discovery.enabled=true",
"spring.cloud.kubernetes.discovery.discovery-server-url=example.com",
"spring.cloud.kubernetes.http.discovery.catalog.watcher.enabled=true");
applicationContextRunner.run(context -> assertThat(context).hasSingleBean(KubernetesCatalogWatch.class));
}
@Test
void discoveryDisabled() {
setup("spring.main.cloud-platform=KUBERNETES", "spring.cloud.config.enabled=false",
"spring.cloud.discovery.enabled=false",
"spring.cloud.kubernetes.discovery.discovery-server-url=example.com");
applicationContextRunner.run(context -> assertThat(context).doesNotHaveBean(KubernetesCatalogWatch.class));
}
@Test
void kubernetesDiscoveryEnabled() {
setup("spring.main.cloud-platform=KUBERNETES", "spring.cloud.config.enabled=false",
"spring.cloud.kubernetes.discovery.enabled=true",
"spring.cloud.kubernetes.http.discovery.catalog.watcher.enabled=true",
"spring.cloud.kubernetes.discovery.discovery-server-url=example.com");
applicationContextRunner.run(context -> assertThat(context).hasSingleBean(KubernetesCatalogWatch.class));
}
// disabling discovery has no impact on the catalog watch.
@Test
void kubernetesDiscoveryDisabled() {
setup("spring.main.cloud-platform=KUBERNETES", "spring.cloud.config.enabled=false",
"spring.cloud.kubernetes.discovery.enabled=false",
"spring.cloud.kubernetes.discovery.discovery-server-url=example.com",
"spring.cloud.kubernetes.http.discovery.catalog.watcher.enabled=true");
applicationContextRunner.run(context -> assertThat(context).hasSingleBean(KubernetesCatalogWatch.class));
}
/**
* both blocking and reactive configs are disabled, should not influence catalog
* watcher in any way.
*/
@Test
void disableBlockingAndReactive() {
setup("spring.main.cloud-platform=KUBERNETES", "spring.cloud.config.enabled=false",
"spring.cloud.discovery.blocking.enabled=false", "spring.cloud.discovery.reactive.enabled=false",
"spring.cloud.kubernetes.http.discovery.catalog.watcher.enabled=true");
applicationContextRunner.run(context -> {
assertThat(context).hasSingleBean(KubernetesCatalogWatch.class);
assertThat(context).doesNotHaveBean(KubernetesDiscoveryClient.class);
assertThat(context).doesNotHaveBean(KubernetesReactiveDiscoveryClient.class);
});
}
/**
* spring.cloud.kubernetes.discovery.enabled is false, but does not influence catalog
* watcher.
*/
@Test
void disableKubernetesDiscovery() {
setup("spring.main.cloud-platform=KUBERNETES", "spring.cloud.config.enabled=false",
"spring.cloud.kubernetes.discovery.enabled=false",
"spring.cloud.kubernetes.http.discovery.catalog.watcher.enabled=true");
applicationContextRunner.run(context -> {
assertThat(context).hasSingleBean(KubernetesCatalogWatch.class);
assertThat(context).doesNotHaveBean(KubernetesDiscoveryClient.class);
assertThat(context).doesNotHaveBean(KubernetesReactiveDiscoveryClient.class);
});
}
/**
* spring.cloud.kubernetes.http.discovery.client.catalog.watcher.enabled is false, as
* such catalog watcher is not present.
*/
@Test
void disableHttpDiscoveryClientCatalogWatcher() {
setup("spring.main.cloud-platform=KUBERNETES", "spring.cloud.config.enabled=true",
"spring.cloud.kubernetes.discovery.enabled=false",
"spring.cloud.kubernetes.http.discovery.catalog.watcher.enabled=false");
applicationContextRunner.run(context -> {
assertThat(context).doesNotHaveBean(KubernetesCatalogWatch.class);
assertThat(context).doesNotHaveBean(KubernetesDiscoveryClient.class);
assertThat(context).doesNotHaveBean(KubernetesReactiveDiscoveryClient.class);
});
}
private void setup(String... properties) {
applicationContextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(KubernetesCatalogWatchAutoConfiguration.class,
KubernetesDiscoveryClientBlockingAutoConfiguration.class,
KubernetesDiscoveryClientReactiveAutoConfiguration.class))
.withPropertyValues(properties);
}
}

View File

@@ -0,0 +1,188 @@
/*
* Copyright 2013-2023 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.kubernetes.discovery;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.WireMock;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.cloud.client.discovery.event.HeartbeatEvent;
import org.springframework.cloud.kubernetes.commons.discovery.EndpointNameAndNamespace;
import org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryProperties;
import org.springframework.context.ApplicationEventPublisher;
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
import static org.mockito.Mockito.verify;
/**
* @author wind57
*/
class KubernetesCatalogWatchTests {
private WireMockServer wireMockServer;
private static final ArgumentCaptor<HeartbeatEvent> HEARTBEAT_EVENT_ARGUMENT_CAPTOR = ArgumentCaptor
.forClass(HeartbeatEvent.class);
private static final ApplicationEventPublisher APPLICATION_EVENT_PUBLISHER = Mockito
.mock(ApplicationEventPublisher.class);
@AfterEach
void afterEach() {
Mockito.reset(APPLICATION_EVENT_PUBLISHER);
}
@Test
void testSingleCycleSameAsCurrentState() {
String body = "[]";
wireMockServer = new WireMockServer(options().dynamicPort());
wireMockServer.start();
WireMock.configureFor(wireMockServer.port());
stubFor(get("/state")
.willReturn(aResponse().withStatus(200).withBody(body).withHeader("content-type", "application/json")));
KubernetesDiscoveryProperties properties = new KubernetesDiscoveryProperties(true, true, Set.of(), true, 60,
false, null, Set.of(), Map.of(), null, KubernetesDiscoveryProperties.Metadata.DEFAULT, 0, false, false,
wireMockServer.baseUrl());
KubernetesCatalogWatch catalogWatch = new KubernetesCatalogWatch(new RestTemplateBuilder(), properties);
catalogWatch.setApplicationEventPublisher(APPLICATION_EVENT_PUBLISHER);
catalogWatch.catalogServicesWatch();
Mockito.verifyNoInteractions(APPLICATION_EVENT_PUBLISHER);
}
@Test
@SuppressWarnings("unchecked")
void testSingleCycleDifferentCurrentState() {
String body = """
[
{
"endpointName":"endpoint-name",
"namespace":"namespaceA"
}
]
""";
wireMockServer = new WireMockServer(options().dynamicPort());
wireMockServer.start();
WireMock.configureFor(wireMockServer.port());
stubFor(get("/state")
.willReturn(aResponse().withStatus(200).withBody(body).withHeader("content-type", "application/json")));
KubernetesDiscoveryProperties properties = new KubernetesDiscoveryProperties(true, true, Set.of(), true, 60,
false, null, Set.of(), Map.of(), null, KubernetesDiscoveryProperties.Metadata.DEFAULT, 0, false, false,
wireMockServer.baseUrl());
KubernetesCatalogWatch catalogWatch = new KubernetesCatalogWatch(new RestTemplateBuilder(), properties);
catalogWatch.setApplicationEventPublisher(APPLICATION_EVENT_PUBLISHER);
catalogWatch.catalogServicesWatch();
verify(APPLICATION_EVENT_PUBLISHER).publishEvent(HEARTBEAT_EVENT_ARGUMENT_CAPTOR.capture());
HeartbeatEvent event = HEARTBEAT_EVENT_ARGUMENT_CAPTOR.getValue();
Assertions.assertEquals(event.getSource().getClass(), KubernetesCatalogWatch.class);
List<EndpointNameAndNamespace> state = (List<EndpointNameAndNamespace>) event.getValue();
Assertions.assertEquals(state.size(), 1);
Assertions.assertEquals(state.get(0).namespace(), "namespaceA");
}
@Test
@SuppressWarnings("unchecked")
void testTwoCyclesDifferentStates() {
String bodyOne = """
[
{
"endpointName":"endpoint-name",
"namespace":"namespaceA"
}
]
""";
// namespace differs
String bodyTwo = """
[
{
"endpointName":"endpoint-name",
"namespace":"namespaceB"
}
]
""";
wireMockServer = new WireMockServer(options().dynamicPort());
wireMockServer.start();
WireMock.configureFor(wireMockServer.port());
stubFor(get("/state").willReturn(
aResponse().withStatus(200).withBody(bodyOne).withHeader("content-type", "application/json")));
KubernetesDiscoveryProperties properties = new KubernetesDiscoveryProperties(true, true, Set.of(), true, 60,
false, null, Set.of(), Map.of(), null, KubernetesDiscoveryProperties.Metadata.DEFAULT, 0, false, false,
wireMockServer.baseUrl());
KubernetesCatalogWatch catalogWatch = new KubernetesCatalogWatch(new RestTemplateBuilder(), properties);
catalogWatch.setApplicationEventPublisher(APPLICATION_EVENT_PUBLISHER);
catalogWatch.catalogServicesWatch();
verify(APPLICATION_EVENT_PUBLISHER).publishEvent(HEARTBEAT_EVENT_ARGUMENT_CAPTOR.capture());
HeartbeatEvent eventOne = HEARTBEAT_EVENT_ARGUMENT_CAPTOR.getValue();
Assertions.assertEquals(eventOne.getSource().getClass(), KubernetesCatalogWatch.class);
List<EndpointNameAndNamespace> stateOne = (List<EndpointNameAndNamespace>) eventOne.getValue();
Assertions.assertEquals(stateOne.size(), 1);
Assertions.assertEquals(stateOne.get(0).namespace(), "namespaceA");
// second call
stubFor(get("/state").willReturn(
aResponse().withStatus(200).withBody(bodyTwo).withHeader("content-type", "application/json")));
catalogWatch.catalogServicesWatch();
verify(APPLICATION_EVENT_PUBLISHER, Mockito.times(2)).publishEvent(HEARTBEAT_EVENT_ARGUMENT_CAPTOR.capture());
HeartbeatEvent eventTwo = HEARTBEAT_EVENT_ARGUMENT_CAPTOR.getValue();
Assertions.assertEquals(eventTwo.getSource().getClass(), KubernetesCatalogWatch.class);
List<EndpointNameAndNamespace> stateTwo = (List<EndpointNameAndNamespace>) eventTwo.getValue();
Assertions.assertEquals(stateTwo.size(), 1);
Assertions.assertEquals(stateTwo.get(0).namespace(), "namespaceB");
}
}

View File

@@ -22,6 +22,7 @@ import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.FilteredClassLoader;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.reactive.function.client.WebClient;
@@ -103,7 +104,7 @@ class KubernetesDiscoveryAutoConfigurationTests {
"spring.cloud.discovery.reactive.enabled=false",
"spring.cloud.kubernetes.discovery.discovery-server-url=http://k8sdiscoveryserver");
applicationContextRunner.run(context -> {
assertThat(context).hasSingleBean(RestTemplate.class);
assertThat(context).hasSingleBean(RestTemplateBuilder.class);
assertThat(context).hasSingleBean(KubernetesDiscoveryClient.class);
assertThat(context).getBean("indicatorInitializer").isNotNull();
@@ -147,7 +148,7 @@ class KubernetesDiscoveryAutoConfigurationTests {
"spring.cloud.discovery.reactive.enabled=false",
"spring.cloud.kubernetes.discovery.discovery-server-url=http://k8sdiscoveryserver");
applicationContextRunner.run(context -> {
assertThat(context).hasSingleBean(RestTemplate.class);
assertThat(context).hasSingleBean(RestTemplateBuilder.class);
assertThat(context).hasSingleBean(KubernetesDiscoveryClient.class);
assertThat(context).doesNotHaveBean("indicatorInitializer");
@@ -169,7 +170,7 @@ class KubernetesDiscoveryAutoConfigurationTests {
"spring.cloud.kubernetes.discovery.discovery-server-url=http://k8sdiscoveryserver",
"spring.cloud.discovery.client.health-indicator.enabled=false");
applicationContextRunner.run(context -> {
assertThat(context).hasSingleBean(RestTemplate.class);
assertThat(context).hasSingleBean(RestTemplateBuilder.class);
assertThat(context).hasSingleBean(KubernetesDiscoveryClient.class);
assertThat(context).doesNotHaveBean("indicatorInitializer");
@@ -191,7 +192,7 @@ class KubernetesDiscoveryAutoConfigurationTests {
"spring.cloud.discovery.reactive.enabled=true",
"spring.cloud.kubernetes.discovery.discovery-server-url=http://k8sdiscoveryserver");
applicationContextRunner.run(context -> {
assertThat(context).hasSingleBean(RestTemplate.class);
assertThat(context).hasSingleBean(RestTemplateBuilder.class);
assertThat(context).hasSingleBean(KubernetesDiscoveryClient.class);
assertThat(context).getBean("indicatorInitializer").isNotNull();
@@ -213,7 +214,7 @@ class KubernetesDiscoveryAutoConfigurationTests {
"spring.cloud.discovery.reactive.enabled=true",
"spring.cloud.kubernetes.discovery.discovery-server-url=http://k8sdiscoveryserver");
applicationContextRunner.run(context -> {
assertThat(context).hasSingleBean(RestTemplate.class);
assertThat(context).hasSingleBean(RestTemplateBuilder.class);
assertThat(context).hasSingleBean(KubernetesDiscoveryClient.class);
assertThat(context).getBean("indicatorInitializer").isNotNull();

View File

@@ -35,6 +35,7 @@ import org.springframework.context.ApplicationEventPublisherAware;
import org.springframework.core.log.LogAccessor;
import org.springframework.scheduling.annotation.Scheduled;
import static org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryConstants.CATALOG_WATCH_PROPERTY_WITH_DEFAULT_VALUE;
import static org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryConstants.DISCOVERY_GROUP;
import static org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryConstants.DISCOVERY_VERSION;
import static org.springframework.cloud.kubernetes.commons.discovery.KubernetesDiscoveryConstants.ENDPOINT_SLICE;
@@ -66,7 +67,7 @@ public class KubernetesCatalogWatch implements ApplicationEventPublisherAware {
this.publisher = publisher;
}
@Scheduled(fixedDelayString = "${spring.cloud.kubernetes.discovery.catalogServicesWatchDelay:30000}")
@Scheduled(fixedDelayString = "${" + CATALOG_WATCH_PROPERTY_WITH_DEFAULT_VALUE + "}")
public void catalogServicesWatch() {
try {

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2013-2023 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.kubernetes.discoveryclient.it;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.commons.logging.LogFactory;
import org.springframework.cloud.client.discovery.event.HeartbeatEvent;
import org.springframework.cloud.kubernetes.commons.discovery.EndpointNameAndNamespace;
import org.springframework.context.ApplicationListener;
import org.springframework.core.log.LogAccessor;
import org.springframework.stereotype.Component;
/**
* @author wind57
*/
@Component
class HeartbeatListener implements ApplicationListener<HeartbeatEvent> {
private static final LogAccessor LOG = new LogAccessor(LogFactory.getLog(HeartbeatListener.class));
AtomicReference<List<EndpointNameAndNamespace>> state = new AtomicReference<>(List.of());
@Override
@SuppressWarnings("unchecked")
public void onApplicationEvent(HeartbeatEvent event) {
LOG.info("received heartbeat event");
List<EndpointNameAndNamespace> state = (List<EndpointNameAndNamespace>) event.getValue();
this.state.set(state);
LOG.info("state received : " + state);
}
}

View File

@@ -22,6 +22,8 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.cloud.kubernetes.commons.discovery.EndpointNameAndNamespace;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@@ -30,13 +32,17 @@ import org.springframework.web.bind.annotation.RestController;
* @author Ryan Baxter
*/
@SpringBootApplication
@EnableScheduling
@RestController
public class KubernetesDiscoveryClientApplicationIt {
class KubernetesDiscoveryClientApplicationIt {
private final DiscoveryClient discoveryClient;
public KubernetesDiscoveryClientApplicationIt(DiscoveryClient discoveryClient) {
private final HeartbeatListener heartbeatListener;
KubernetesDiscoveryClientApplicationIt(DiscoveryClient discoveryClient, HeartbeatListener heartbeatListener) {
this.discoveryClient = discoveryClient;
this.heartbeatListener = heartbeatListener;
}
public static void main(String[] args) {
@@ -44,13 +50,18 @@ public class KubernetesDiscoveryClientApplicationIt {
}
@GetMapping("/services")
public List<String> services() {
List<String> services() {
return discoveryClient.getServices();
}
@GetMapping("/service/{serviceId}")
public List<ServiceInstance> service(@PathVariable String serviceId) {
List<ServiceInstance> service(@PathVariable String serviceId) {
return discoveryClient.getInstances(serviceId);
}
@GetMapping("/state")
List<EndpointNameAndNamespace> state() {
return heartbeatListener.state.get();
}
}

View File

@@ -17,14 +17,18 @@
package org.springframework.cloud.kubernetes.discoveryclient.it;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Objects;
import org.assertj.core.api.Assertions;
import org.assertj.core.api.Condition;
import org.testcontainers.k3s.K3sContainer;
import reactor.netty.http.client.HttpClient;
import reactor.util.retry.Retry;
import reactor.util.retry.RetryBackoffSpec;
import org.springframework.boot.test.json.BasicJsonTester;
import org.springframework.cloud.kubernetes.integration.tests.commons.Commons;
import org.springframework.http.HttpMethod;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
@@ -34,12 +38,17 @@ import org.springframework.web.reactive.function.client.WebClient;
*/
final class DiscoveryClientFilterNamespaceDelegate {
private DiscoveryClientFilterNamespaceDelegate() {
}
private static final BasicJsonTester BASIC_JSON_TESTER = new BasicJsonTester(
DiscoveryClientFilterNamespaceDelegate.class);
static void testNamespaceDiscoveryClient() {
static void testNamespaceDiscoveryClient(K3sContainer container) {
testLoadBalancer();
testHealth();
testForHeartbeat(container);
}
private static void testLoadBalancer() {
@@ -76,6 +85,42 @@ final class DiscoveryClientFilterNamespaceDelegate {
.extractingJsonPathStringValue("$.components.discoveryComposite.status").isEqualTo("UP");
}
private static void testForHeartbeat(K3sContainer container) {
// 1. logs from discovery server
Commons.waitForLogStatement("using delay : 3000", container, "spring-cloud-kubernetes-discoveryserver");
Commons.waitForLogStatement("received heartbeat event", container, "spring-cloud-kubernetes-discoveryserver");
Commons.waitForLogStatement("state received :", container, "spring-cloud-kubernetes-discoveryserver");
// 2. logs from discovery client
Commons.waitForLogStatement("using delay : 3000", container,
"spring-cloud-kubernetes-k8s-client-discovery-server");
Commons.waitForLogStatement("state received : ", container,
"spring-cloud-kubernetes-k8s-client-discovery-server");
// 3. heartbeat listener message
WebClient.Builder builder = builder();
WebClient client = builder.baseUrl("http://localhost:80/discoveryclient-it/state").build();
String result = client.method(HttpMethod.GET).retrieve().bodyToMono(String.class).retryWhen(retrySpec())
.block();
Condition<LinkedHashMap<String, String>> wireMockService = new Condition<>(
map -> map.entrySet().stream().anyMatch(en -> en.getValue().contains("service-wiremock-deployment")),
"");
Condition<LinkedHashMap<String, String>> discoveryServerService = new Condition<>(
map -> map.entrySet().stream()
.anyMatch(en -> en.getValue().contains("spring-cloud-kubernetes-k8s-client-discovery-server")),
"");
Assertions.assertThat(BASIC_JSON_TESTER.from(result))
.<LinkedHashMap<String, String>>extractingJsonPathArrayValue("$.[*]").areAtLeastOne(wireMockService);
Assertions.assertThat(BASIC_JSON_TESTER.from(result))
.<LinkedHashMap<String, String>>extractingJsonPathArrayValue("$.[*]")
.areAtLeastOne(discoveryServerService);
}
private static WebClient.Builder builder() {
return WebClient.builder().clientConnector(new ReactorClientHttpConnector(HttpClient.create()));
}

View File

@@ -19,6 +19,7 @@ package org.springframework.cloud.kubernetes.discoveryclient.it;
import java.time.Duration;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import io.kubernetes.client.openapi.apis.RbacAuthorizationV1Api;
import io.kubernetes.client.openapi.models.V1ClusterRoleBinding;
@@ -62,6 +63,18 @@ class DiscoveryClientIT {
{
"name": "SPRING_CLOUD_KUBERNETES_DISCOVERY_NAMESPACES_0",
"value": "left"
},
{
"name": "SPRING_CLOUD_KUBERNETES_DISCOVERY_CATALOGSERVICESWATCHDELAY",
"value": "3000"
},
{
"name": "LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_KUBERNETES_DISCOVERY",
"value": "DEBUG"
},
{
"name": "SPRING_CLOUD_KUBERNETES_HTTP_DISCOVERY_CATALOG_WATCHER_ENABLED",
"value": "TRUE"
}
]
}]
@@ -83,6 +96,18 @@ class DiscoveryClientIT {
{
"name": "SPRING_CLOUD_KUBERNETES_DISCOVERY_ALL_NAMESPACES",
"value": "TRUE"
},
{
"name": "LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_KUBERNETES_DISCOVERYSERVER",
"value": "DEBUG"
},
{
"name": "SPRING_CLOUD_KUBERNETES_DISCOVERY_CATALOGSERVICESWATCHDELAY",
"value": "3000"
},
{
"name": "SPRING_CLOUD_KUBERNETES_HTTP_DISCOVERY_CATALOG_WATCHER_ENABLED",
"value": "TRUE"
}
]
}]
@@ -160,22 +185,22 @@ class DiscoveryClientIT {
util.deleteNamespace(NAMESPACE_RIGHT);
discoveryServer(Phase.DELETE);
discoveryIt(Phase.DELETE);
discoveryClient(Phase.DELETE);
Commons.systemPrune();
}
@Test
void testDiscoveryClient() {
discoveryIt(Phase.CREATE);
discoveryClient(Phase.CREATE);
testLoadBalancer();
testHealth();
patchForAllNamespaces("docker.io/springcloud/spring-cloud-kubernetes-discoveryserver:" + Commons.pomVersion(),
"spring-cloud-kubernetes-discoveryserver-deployment", NAMESPACE);
patchForNamespaceFilter(
"docker.io/springcloud/spring-cloud-kubernetes-k8s-client-discovery-server:" + Commons.pomVersion(),
"spring-cloud-kubernetes-k8s-client-discovery-server-deployment", NAMESPACE);
patchForAllNamespaces("docker.io/springcloud/spring-cloud-kubernetes-discoveryserver:" + Commons.pomVersion(),
"spring-cloud-kubernetes-discoveryserver-deployment", NAMESPACE);
testNamespaceDiscoveryClient();
testNamespaceDiscoveryClient(K3S);
}
private void testLoadBalancer() {
@@ -187,6 +212,31 @@ class DiscoveryClientIT {
Assertions.assertThat(BASIC_JSON_TESTER.from(result)).extractingJsonPathArrayValue("$")
.contains("spring-cloud-kubernetes-discoveryserver");
// since 'spring.cloud.kubernetes.http.discovery.client.catalog.watcher.enabled'
// is false by default, we will not receive any heartbeat events,
// simply because there are no beans registered to provide that to us.
// We assert this by doing a call to our internal /state
// endpoint, waiting 10 seconds and doing it again. Since the watch delay is set
// to 3 seconds, if there would be proper events,
// we would get a result that is different from '[]'.
WebClient.Builder stateBuilder = builder();
WebClient client = stateBuilder.baseUrl("http://localhost:80/discoveryclient-it/state").build();
String stateResult = client.method(HttpMethod.GET).retrieve().bodyToMono(String.class).retryWhen(retrySpec())
.block();
Assertions.assertThat(BASIC_JSON_TESTER.from(stateResult)).isEqualTo("[]");
try {
Thread.sleep(TimeUnit.SECONDS.toMillis(10));
}
catch (InterruptedException e) {
throw new RuntimeException(e);
}
String stateResultAfter10Seconds = client.method(HttpMethod.GET).retrieve().bodyToMono(String.class)
.retryWhen(retrySpec()).block();
Assertions.assertThat(BASIC_JSON_TESTER.from(stateResultAfter10Seconds)).isEqualTo("[]");
}
void testHealth() {
@@ -200,7 +250,7 @@ class DiscoveryClientIT {
.extractingJsonPathStringValue("$.components.discoveryComposite.status").isEqualTo("UP");
}
private static void discoveryIt(Phase phase) {
private static void discoveryClient(Phase phase) {
V1Deployment deployment = (V1Deployment) util
.yaml("client/spring-cloud-kubernetes-discoveryclient-it-deployment.yaml");
V1Service service = (V1Service) util.yaml("client/spring-cloud-kubernetes-discoveryclient-it-service.yaml");

View File

@@ -30,5 +30,8 @@ spec:
initialDelaySeconds: 10
periodSeconds: 2
failureThreshold: 3
env:
- name: SPRING_CLOUD_KUBERNETES_DISCOVERY_CATALOGSERVICESWATCHDELAY
value: "3000"
ports:
- containerPort: 8080

View File

@@ -24,5 +24,10 @@ spec:
httpGet:
port: 8761
path: /actuator/health/liveness
env:
- name: LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_KUBERNETES_DISCOVERYSERVER
value: "DEBUG"
- name: SPRING_CLOUD_KUBERNETES_DISCOVERY_CATALOGSERVICESWATCHDELAY
value: "3000"
ports:
- containerPort: 8761