From d33764d2329fc54c81febe19e5f8dff3343cdda1 Mon Sep 17 00:00:00 2001 From: erabii Date: Thu, 14 Nov 2024 16:59:00 +0200 Subject: [PATCH 01/16] align annotations (#1793) --- ...ubernetesClientBootstrapConfiguration.java | 2 + ...ientConfigReloadAutoConfigurationTest.java | 45 ++++++++++--------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/spring-cloud-kubernetes-client-config/src/main/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientBootstrapConfiguration.java b/spring-cloud-kubernetes-client-config/src/main/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientBootstrapConfiguration.java index 91ae4eb408..9d6992d4be 100644 --- a/spring-cloud-kubernetes-client-config/src/main/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientBootstrapConfiguration.java +++ b/spring-cloud-kubernetes-client-config/src/main/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientBootstrapConfiguration.java @@ -31,6 +31,7 @@ import org.springframework.cloud.kubernetes.commons.config.ConfigMapConfigProperties; import org.springframework.cloud.kubernetes.commons.config.KubernetesBootstrapConfiguration; import org.springframework.cloud.kubernetes.commons.config.SecretsConfigProperties; +import org.springframework.cloud.util.ConditionalOnBootstrapEnabled; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; @@ -41,6 +42,7 @@ @Configuration(proxyBeanMethods = false) @AutoConfigureAfter(KubernetesBootstrapConfiguration.class) @Import({ KubernetesCommonsAutoConfiguration.class, KubernetesClientAutoConfiguration.class }) +@ConditionalOnBootstrapEnabled @ConditionalOnCloudPlatform(CloudPlatform.KUBERNETES) public class KubernetesClientBootstrapConfiguration { diff --git a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientConfigReloadAutoConfigurationTest.java b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientConfigReloadAutoConfigurationTest.java index f0bfc7fafb..a78b344d2b 100644 --- a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientConfigReloadAutoConfigurationTest.java +++ b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientConfigReloadAutoConfigurationTest.java @@ -174,7 +174,8 @@ void kubernetesReloadEnabledButSecretAndConfigDisabled() { */ @Test void reloadEventEnabledMonitoringConfigMapsEnabledByDefault() { - setup("spring.cloud.kubernetes.reload.enabled=true", "spring.main.cloud-platform=KUBERNETES"); + setup("spring.cloud.bootstrap.enabled=true", "spring.cloud.kubernetes.reload.enabled=true", + "spring.main.cloud-platform=KUBERNETES"); Map map = context.getBeansOfType(ConfigurationChangeDetector.class); Assertions.assertEquals(map.size(), 1); Assertions.assertTrue(map.values() @@ -197,8 +198,8 @@ void reloadEventEnabledMonitoringConfigMapsEnabledByDefault() { */ @Test void reloadEventEnabledMonitoringConfigMapsEnabled() { - setup("spring.cloud.kubernetes.reload.enabled=true", "spring.cloud.kubernetes.reload.mode=event", - "spring.main.cloud-platform=KUBERNETES"); + setup("spring.cloud.bootstrap.enabled=true", "spring.cloud.kubernetes.reload.enabled=true", + "spring.cloud.kubernetes.reload.mode=event", "spring.main.cloud-platform=KUBERNETES"); Map map = context.getBeansOfType(ConfigurationChangeDetector.class); Assertions.assertEquals(map.size(), 1); Assertions.assertTrue(map.values() @@ -259,8 +260,8 @@ void reloadPollingEnabledMonitoringConfigMapsDisabledMonitoringSecretsDisabled() */ @Test void reloadPollingEnabledMonitoringConfigMapsEnabled() { - setup("spring.cloud.kubernetes.reload.enabled=true", "spring.cloud.kubernetes.reload.mode=polling", - "spring.main.cloud-platform=KUBERNETES"); + setup("spring.cloud.bootstrap.enabled=true", "spring.cloud.kubernetes.reload.enabled=true", + "spring.cloud.kubernetes.reload.mode=polling", "spring.main.cloud-platform=KUBERNETES"); Map map = context.getBeansOfType(ConfigurationChangeDetector.class); Assertions.assertEquals(map.size(), 1); Assertions.assertTrue( @@ -281,8 +282,8 @@ void reloadPollingEnabledMonitoringConfigMapsEnabled() { */ @Test void reloadEventEnabledMonitoringConfigMapsDisabledMonitoringSecretsEnabled() { - setup("spring.cloud.kubernetes.reload.enabled=true", "spring.main.cloud-platform=KUBERNETES", - "spring.cloud.kubernetes.reload.monitoring-secrets=true", + setup("spring.cloud.bootstrap.enabled=true", "spring.cloud.kubernetes.reload.enabled=true", + "spring.main.cloud-platform=KUBERNETES", "spring.cloud.kubernetes.reload.monitoring-secrets=true", "spring.cloud.kubernetes.reload.monitoring-configMaps=false", "spring.cloud.kubernetes.reload.mode=event"); Map map = context.getBeansOfType(ConfigurationChangeDetector.class); @@ -308,8 +309,8 @@ void reloadEventEnabledMonitoringConfigMapsDisabledMonitoringSecretsEnabled() { */ @Test void reloadPollingEnabledMonitoringConfigMapsDisabledMonitoringSecretsEnabled() { - setup("spring.cloud.kubernetes.reload.enabled=true", "spring.main.cloud-platform=KUBERNETES", - "spring.cloud.kubernetes.reload.monitoring-secrets=true", + setup("spring.cloud.bootstrap.enabled=true", "spring.cloud.kubernetes.reload.enabled=true", + "spring.main.cloud-platform=KUBERNETES", "spring.cloud.kubernetes.reload.monitoring-secrets=true", "spring.cloud.kubernetes.reload.monitoring-configMaps=false", "spring.cloud.kubernetes.reload.mode=polling"); Map map = context.getBeansOfType(ConfigurationChangeDetector.class); @@ -332,8 +333,8 @@ void reloadPollingEnabledMonitoringConfigMapsDisabledMonitoringSecretsEnabled() */ @Test void reloadEventEnabledMonitoringConfigMapsEnabledMonitoringSecretsEnabled() { - setup("spring.cloud.kubernetes.reload.enabled=true", "spring.main.cloud-platform=KUBERNETES", - "spring.cloud.kubernetes.reload.monitoring-secrets=true", + setup("spring.cloud.bootstrap.enabled=true", "spring.cloud.kubernetes.reload.enabled=true", + "spring.main.cloud-platform=KUBERNETES", "spring.cloud.kubernetes.reload.monitoring-secrets=true", "spring.cloud.kubernetes.reload.monitoring-configMaps=true", "spring.cloud.kubernetes.reload.mode=event"); Map map = context.getBeansOfType(ConfigurationChangeDetector.class); @@ -360,8 +361,8 @@ void reloadEventEnabledMonitoringConfigMapsEnabledMonitoringSecretsEnabled() { */ @Test void reloadPollingEnabledMonitoringConfigMapsEnabledMonitoringSecretsEnabled() { - setup("spring.cloud.kubernetes.reload.enabled=true", "spring.main.cloud-platform=KUBERNETES", - "spring.cloud.kubernetes.reload.monitoring-secrets=true", + setup("spring.cloud.bootstrap.enabled=true", "spring.cloud.kubernetes.reload.enabled=true", + "spring.main.cloud-platform=KUBERNETES", "spring.cloud.kubernetes.reload.monitoring-secrets=true", "spring.cloud.kubernetes.reload.monitoring-configMaps=true", "spring.cloud.kubernetes.reload.mode=polling"); Map map = context.getBeansOfType(ConfigurationChangeDetector.class); @@ -430,8 +431,9 @@ void reloadPollingEnabledMonitorConfigMapsDisabledMonitoringSecretsDisabled() { */ @Test void reloadEventEnabledMonitoringConfigMapsEnabledMonitoringSecretsDisabled() { - setup("spring.cloud.kubernetes.reload.enabled=true", "spring.cloud.kubernetes.reload.mode=event", - "spring.main.cloud-platform=KUBERNETES", "spring.cloud.kubernetes.reload.monitoring-configMaps=true", + setup("spring.cloud.bootstrap.enabled=true", "spring.cloud.kubernetes.reload.enabled=true", + "spring.cloud.kubernetes.reload.mode=event", "spring.main.cloud-platform=KUBERNETES", + "spring.cloud.kubernetes.reload.monitoring-configMaps=true", "spring.cloud.kubernetes.reload.monitoring-secrets=false"); Map map = context.getBeansOfType(ConfigurationChangeDetector.class); Assertions.assertEquals(map.size(), 1); @@ -456,8 +458,9 @@ void reloadEventEnabledMonitoringConfigMapsEnabledMonitoringSecretsDisabled() { */ @Test void reloadPollingEnabledMonitoringConfigMapsEnabledMonitoringSecretsDisabled() { - setup("spring.cloud.kubernetes.reload.enabled=true", "spring.cloud.kubernetes.reload.mode=polling", - "spring.main.cloud-platform=KUBERNETES", "spring.cloud.kubernetes.reload.monitoring-configMaps=true", + setup("spring.cloud.bootstrap.enabled=true", "spring.cloud.kubernetes.reload.enabled=true", + "spring.cloud.kubernetes.reload.mode=polling", "spring.main.cloud-platform=KUBERNETES", + "spring.cloud.kubernetes.reload.monitoring-configMaps=true", "spring.cloud.kubernetes.reload.monitoring-secrets=false"); Map map = context.getBeansOfType(ConfigurationChangeDetector.class); Assertions.assertEquals(map.size(), 1); @@ -472,21 +475,23 @@ void reloadPollingEnabledMonitoringConfigMapsEnabledMonitoringSecretsDisabled() @Test void kubernetesConfigAndSecretEnabledByDefault() { - setup("spring.main.cloud-platform=KUBERNETES"); + setup("spring.cloud.bootstrap.enabled=true", "spring.main.cloud-platform=KUBERNETES"); assertThat(context.containsBean("configMapPropertySourceLocator")).isTrue(); assertThat(context.containsBean("secretsPropertySourceLocator")).isTrue(); } @Test void kubernetesConfigEnabledButSecretDisabled() { - setup("spring.cloud.kubernetes.secrets.enabled=false", "spring.main.cloud-platform=KUBERNETES"); + setup("spring.cloud.bootstrap.enabled=true", "spring.cloud.kubernetes.secrets.enabled=false", + "spring.main.cloud-platform=KUBERNETES"); assertThat(context.containsBean("configMapPropertySourceLocator")).isTrue(); assertThat(context.containsBean("secretsPropertySourceLocator")).isFalse(); } @Test void kubernetesSecretsEnabledButConfigDisabled() { - setup("spring.cloud.kubernetes.config.enabled=false", "spring.main.cloud-platform=KUBERNETES"); + setup("spring.cloud.bootstrap.enabled=true", "spring.cloud.kubernetes.config.enabled=false", + "spring.main.cloud-platform=KUBERNETES"); assertThat(context.containsBean("configMapPropertySourceLocator")).isFalse(); assertThat(context.containsBean("secretsPropertySourceLocator")).isTrue(); } From 59d94d6c1c2efe57661380cda8f61462aa19c7a2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 14:46:09 -0500 Subject: [PATCH 02/16] Bump @antora/collector-extension in /docs (#1797) --- updated-dependencies: - dependency-name: "@antora/collector-extension" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/package.json b/docs/package.json index 872725687a..4ba0c41216 100644 --- a/docs/package.json +++ b/docs/package.json @@ -2,7 +2,7 @@ "dependencies": { "antora": "3.2.0-alpha.4", "@antora/atlas-extension": "1.0.0-alpha.2", - "@antora/collector-extension": "1.0.0-beta.4", + "@antora/collector-extension": "1.0.0-beta.5", "@asciidoctor/tabs": "1.0.0-beta.6", "@springio/antora-extensions": "1.11.1", "@springio/asciidoctor-extensions": "1.0.0-alpha.14" From abc0a89da63d4dc2a29115584a97fa1267492ed7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 14:46:42 -0500 Subject: [PATCH 03/16] Bump @antora/collector-extension in /docs (#1796) --- updated-dependencies: - dependency-name: "@antora/collector-extension" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/package.json b/docs/package.json index 872725687a..4ba0c41216 100644 --- a/docs/package.json +++ b/docs/package.json @@ -2,7 +2,7 @@ "dependencies": { "antora": "3.2.0-alpha.4", "@antora/atlas-extension": "1.0.0-alpha.2", - "@antora/collector-extension": "1.0.0-beta.4", + "@antora/collector-extension": "1.0.0-beta.5", "@asciidoctor/tabs": "1.0.0-beta.6", "@springio/antora-extensions": "1.11.1", "@springio/asciidoctor-extensions": "1.0.0-alpha.14" From 890af8f20206019640047740d677f937e470fcf7 Mon Sep 17 00:00:00 2001 From: Yanming Zhou Date: Tue, 19 Nov 2024 04:29:19 +0800 Subject: [PATCH 04/16] Fix outdated branch name (#1794) --- docs/modules/ROOT/pages/discovery-client.adoc | 2 +- docs/modules/ROOT/pages/examples.adoc | 2 +- docs/modules/ROOT/pages/pod-health-indicator.adoc | 2 +- .../pages/property-source-config/configmap-propertysource.adoc | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/modules/ROOT/pages/discovery-client.adoc b/docs/modules/ROOT/pages/discovery-client.adoc index 8383ff59cd..9ef1100dec 100644 --- a/docs/modules/ROOT/pages/discovery-client.adoc +++ b/docs/modules/ROOT/pages/discovery-client.adoc @@ -1,7 +1,7 @@ [[discoveryclient-for-kubernetes]] = DiscoveryClient for Kubernetes -This project provides an implementation of https://github.com/spring-cloud/spring-cloud-commons/blob/master/spring-cloud-commons/src/main/java/org/springframework/cloud/client/discovery/DiscoveryClient.java[Discovery Client] +This project provides an implementation of https://github.com/spring-cloud/spring-cloud-commons/blob/main/spring-cloud-commons/src/main/java/org/springframework/cloud/client/discovery/DiscoveryClient.java[Discovery Client] for https://kubernetes.io[Kubernetes]. This client lets you query Kubernetes endpoints (see https://kubernetes.io/docs/user-guide/services/[services]) by name. A service is typically exposed by the Kubernetes API server as a collection of endpoints that represent `http` and `https` addresses and that a client can diff --git a/docs/modules/ROOT/pages/examples.adoc b/docs/modules/ROOT/pages/examples.adoc index ca0b11ec72..d78f857dec 100644 --- a/docs/modules/ROOT/pages/examples.adoc +++ b/docs/modules/ROOT/pages/examples.adoc @@ -10,7 +10,7 @@ The same applies for `PropertySourceLocator`, where you need to add to the class The following projects highlight the usage of these dependencies and demonstrate how you can use these libraries from any Spring Boot application: -* https://github.com/spring-cloud/spring-cloud-kubernetes/tree/master/spring-cloud-kubernetes-examples[Spring Cloud Kubernetes Examples]: the ones located inside this repository. +* https://github.com/spring-cloud/spring-cloud-kubernetes/tree/main/spring-cloud-kubernetes-examples[Spring Cloud Kubernetes Examples]: the ones located inside this repository. * Spring Cloud Kubernetes Full Example: Minions and Boss ** https://github.com/salaboy/spring-cloud-k8s-minion[Minion] ** https://github.com/salaboy/spring-cloud-k8s-boss[Boss] diff --git a/docs/modules/ROOT/pages/pod-health-indicator.adoc b/docs/modules/ROOT/pages/pod-health-indicator.adoc index 80b812842d..aba49a15a6 100644 --- a/docs/modules/ROOT/pages/pod-health-indicator.adoc +++ b/docs/modules/ROOT/pages/pod-health-indicator.adoc @@ -2,7 +2,7 @@ = Pod Health Indicator :page-section-summary-toc: 1 -Spring Boot uses https://github.com/spring-projects/spring-boot/blob/master/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthEndpoint.java[`HealthIndicator`] to expose info about the health of an application. +Spring Boot uses https://github.com/spring-projects/spring-boot/blob/main/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthEndpoint.java[`HealthIndicator`] to expose info about the health of an application. That makes it really useful for exposing health-related information to the user and makes it a good fit for use as https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-probes/[readiness probes]. The Kubernetes health indicator (which is part of the core module) exposes the following info: diff --git a/docs/modules/ROOT/pages/property-source-config/configmap-propertysource.adoc b/docs/modules/ROOT/pages/property-source-config/configmap-propertysource.adoc index 64727ccd26..ef7b479024 100644 --- a/docs/modules/ROOT/pages/property-source-config/configmap-propertysource.adoc +++ b/docs/modules/ROOT/pages/property-source-config/configmap-propertysource.adoc @@ -3,7 +3,7 @@ Kubernetes provides a resource named https://kubernetes.io/docs/user-guide/configmap/[`ConfigMap`] to externalize the parameters to pass to your application in the form of key-value pairs or embedded `application.properties` or `application.yaml` files. -The link:https://github.com/spring-cloud/spring-cloud-kubernetes/tree/master/spring-cloud-kubernetes-fabric8-config[Spring Cloud Kubernetes Config] project makes Kubernetes `ConfigMap` instances available +The link:https://github.com/spring-cloud/spring-cloud-kubernetes/tree/main/spring-cloud-kubernetes-fabric8-config[Spring Cloud Kubernetes Config] project makes Kubernetes `ConfigMap` instances available during application startup and triggers hot reloading of beans or Spring context when changes are detected on observed `ConfigMap` instances. From 16eec95fd0b8faecc841fb6035db123b1f0af5ce Mon Sep 17 00:00:00 2001 From: Ryan Baxter Date: Thu, 21 Nov 2024 18:55:58 -0500 Subject: [PATCH 05/16] Add support for the configuration watcher to shut down the application to refresh the application (#1799) See #1772 --- ...loud-kubernetes-configuration-watcher.adoc | 11 +- .../KubernetesDiscoveryProperties.java | 2 +- .../watcher/BusRefreshTrigger.java | 22 +++- ...urationWatcherConfigurationProperties.java | 26 ++++ .../watcher/HttpRefreshTrigger.java | 13 +- .../RefreshTriggerAutoConfiguration.java | 4 +- ...edConfigMapWatcherChangeDetectorTests.java | 46 +++++-- ...asedSecretsWatcherChangeDetectorTests.java | 48 ++++++-- ...asedConfigMapWatchChangeDetectorTests.java | 112 +++++++++++++----- ...pBasedSecretsWatchChangeDetectorTests.java | 85 +++++++++---- .../watcher/ActuatorRefreshIT.java | 26 ++++ .../configuration/watcher/TestUtil.java | 33 ++++++ 12 files changed, 343 insertions(+), 85 deletions(-) diff --git a/docs/modules/ROOT/pages/spring-cloud-kubernetes-configuration-watcher.adoc b/docs/modules/ROOT/pages/spring-cloud-kubernetes-configuration-watcher.adoc index c93649c095..30b6f17f49 100644 --- a/docs/modules/ROOT/pages/spring-cloud-kubernetes-configuration-watcher.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-kubernetes-configuration-watcher.adoc @@ -193,6 +193,9 @@ change to a ConfigMap or Secret occurs then the HTTP implementation will use the instances of the application which match the name of the ConfigMap or Secret and send an HTTP POST request to the application's actuator `/refresh` endpoint. By default, it will send the post request to `/actuator/refresh` using the port registered in the discovery client. +You can also configure the configuration watcher to call the instances `shutdown` actuator endpoint. To do this you can set +`spring.cloud.kubernetes.configuration.watcher.refresh-strategy=shutdown`. + ### Non-Default Management Port and Actuator Path If the application is using a non-default actuator path and/or using a different port for the management endpoints, the Kubernetes service for the application @@ -224,7 +227,13 @@ Another way you can choose to configure the actuator path and/or management port ## Messaging Implementation The messaging implementation can be enabled by setting profile to either `bus-amqp` (RabbitMQ) or `bus-kafka` (Kafka) when the Spring Cloud Kubernetes Configuration Watcher -application is deployed to Kubernetes. +application is deployed to Kubernetes. By default, when using the messaging implementation the configuration watcher will send a `RefreshRemoteApplicationEvent` using +Spring Cloud Bus to all application instances. This will cause the application instances to refresh the application's configuration properties without +restarting the instance. + +You can also configure the configuration to shut down the application instances in order to refresh the application's configuration properties. +When the application shuts down, Kubernetes will restart the application instance and the new configuration properties will be loaded. To use +this strategy set `spring.cloud.kubernetes.configuration.watcher.refresh-strategy=shutdown`. ## Configuring RabbitMQ diff --git a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/discovery/KubernetesDiscoveryProperties.java b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/discovery/KubernetesDiscoveryProperties.java index a07f367ae4..1bee49cc20 100644 --- a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/discovery/KubernetesDiscoveryProperties.java +++ b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/discovery/KubernetesDiscoveryProperties.java @@ -27,7 +27,7 @@ /** * @param enabled if kubernetes discovery is enabled - * @param allNamespaces if discover is enabled for all namespaces + * @param allNamespaces if discovery is enabled for all namespaces * @param namespaces If set and allNamespaces is false, then only the services and * endpoints matching these namespaces will be fetched from the Kubernetes API server. * @param waitCacheReady wait for the discovery cache (service and endpoints) to be fully diff --git a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/main/java/org/springframework/cloud/kubernetes/configuration/watcher/BusRefreshTrigger.java b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/main/java/org/springframework/cloud/kubernetes/configuration/watcher/BusRefreshTrigger.java index 411fa68404..b88dec6a5c 100644 --- a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/main/java/org/springframework/cloud/kubernetes/configuration/watcher/BusRefreshTrigger.java +++ b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/main/java/org/springframework/cloud/kubernetes/configuration/watcher/BusRefreshTrigger.java @@ -21,8 +21,12 @@ import org.springframework.cloud.bus.event.PathDestinationFactory; import org.springframework.cloud.bus.event.RefreshRemoteApplicationEvent; +import org.springframework.cloud.bus.event.RemoteApplicationEvent; +import org.springframework.cloud.bus.event.ShutdownRemoteApplicationEvent; import org.springframework.context.ApplicationEventPublisher; +import static org.springframework.cloud.kubernetes.configuration.watcher.ConfigurationWatcherConfigurationProperties.RefreshStrategy.SHUTDOWN; + /** * An event publisher for an 'event bus' type of application. * @@ -34,16 +38,28 @@ final class BusRefreshTrigger implements RefreshTrigger { private final String busId; - BusRefreshTrigger(ApplicationEventPublisher applicationEventPublisher, String busId) { + private final ConfigurationWatcherConfigurationProperties watcherConfigurationProperties; + + BusRefreshTrigger(ApplicationEventPublisher applicationEventPublisher, String busId, + ConfigurationWatcherConfigurationProperties watcherConfigurationProperties) { this.applicationEventPublisher = applicationEventPublisher; this.busId = busId; + this.watcherConfigurationProperties = watcherConfigurationProperties; } @Override public Mono triggerRefresh(KubernetesObject configMap, String appName) { - applicationEventPublisher.publishEvent(new RefreshRemoteApplicationEvent(configMap, busId, - new PathDestinationFactory().getDestination(appName))); + applicationEventPublisher.publishEvent(createRefreshApplicationEvent(configMap, appName)); return Mono.empty(); } + private RemoteApplicationEvent createRefreshApplicationEvent(KubernetesObject configMap, String appName) { + if (watcherConfigurationProperties.getRefreshStrategy() == SHUTDOWN) { + return new ShutdownRemoteApplicationEvent(configMap, busId, + new PathDestinationFactory().getDestination(appName)); + } + return new RefreshRemoteApplicationEvent(configMap, busId, + new PathDestinationFactory().getDestination(appName)); + } + } diff --git a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/main/java/org/springframework/cloud/kubernetes/configuration/watcher/ConfigurationWatcherConfigurationProperties.java b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/main/java/org/springframework/cloud/kubernetes/configuration/watcher/ConfigurationWatcherConfigurationProperties.java index 47e9474d5b..964a13cf72 100644 --- a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/main/java/org/springframework/cloud/kubernetes/configuration/watcher/ConfigurationWatcherConfigurationProperties.java +++ b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/main/java/org/springframework/cloud/kubernetes/configuration/watcher/ConfigurationWatcherConfigurationProperties.java @@ -70,6 +70,8 @@ public class ConfigurationWatcherConfigurationProperties { @DurationUnit(ChronoUnit.MILLIS) private Duration refreshDelay = Duration.ofMillis(120000); + private RefreshStrategy refreshStrategy = RefreshStrategy.REFRESH; + private int threadPoolSize = 1; private String actuatorPath = "/actuator"; @@ -115,4 +117,28 @@ public void setThreadPoolSize(int threadPoolSize) { this.threadPoolSize = threadPoolSize; } + public RefreshStrategy getRefreshStrategy() { + return refreshStrategy; + } + + public void setRefreshStrategy(RefreshStrategy refreshStrategy) { + this.refreshStrategy = refreshStrategy; + } + + public enum RefreshStrategy { + + /** + * Call the Actuator refresh endpoint or send a refresh event over Spring Cloud + * Bus. + */ + REFRESH, + + /** + * Call the Actuator shutdown endpoint or send a shutdown event over Spring Cloud + * Bus. + */ + SHUTDOWN + + } + } diff --git a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/main/java/org/springframework/cloud/kubernetes/configuration/watcher/HttpRefreshTrigger.java b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/main/java/org/springframework/cloud/kubernetes/configuration/watcher/HttpRefreshTrigger.java index 29ef23d3dc..91ba617f05 100644 --- a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/main/java/org/springframework/cloud/kubernetes/configuration/watcher/HttpRefreshTrigger.java +++ b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/main/java/org/springframework/cloud/kubernetes/configuration/watcher/HttpRefreshTrigger.java @@ -31,6 +31,8 @@ import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.util.UriComponentsBuilder; +import static org.springframework.cloud.kubernetes.configuration.watcher.ConfigurationWatcherConfigurationProperties.RefreshStrategy.SHUTDOWN; + /** * @author wind57 */ @@ -91,7 +93,7 @@ private URI getActuatorUri(ServiceInstance si, String actuatorPath, int actuator } else { int port = actuatorPort < 0 ? si.getPort() : actuatorPort; - actuatorUriBuilder = actuatorUriBuilder.path(actuatorPath + "/refresh").port(port); + actuatorUriBuilder = actuatorUriBuilder.path(actuatorPath + getRefreshStrategyEndpoint()).port(port); } return actuatorUriBuilder.build().toUri(); @@ -99,7 +101,7 @@ private URI getActuatorUri(ServiceInstance si, String actuatorPath, int actuator private void setActuatorUriFromAnnotation(UriComponentsBuilder actuatorUriBuilder, String metadataUri) { URI annotationUri = URI.create(metadataUri); - actuatorUriBuilder.path(annotationUri.getPath() + "/refresh"); + actuatorUriBuilder.path(annotationUri.getPath() + getRefreshStrategyEndpoint()); // The URI may not contain a host so if that is the case the port in the URI will // be -1. The authority of the URI will be : for example :9090, we just need @@ -114,4 +116,11 @@ private void setActuatorUriFromAnnotation(UriComponentsBuilder actuatorUriBuilde } } + private String getRefreshStrategyEndpoint() { + if (k8SConfigurationProperties.getRefreshStrategy() == SHUTDOWN) { + return "/shutdown"; + } + return "/refresh"; + } + } diff --git a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/main/java/org/springframework/cloud/kubernetes/configuration/watcher/RefreshTriggerAutoConfiguration.java b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/main/java/org/springframework/cloud/kubernetes/configuration/watcher/RefreshTriggerAutoConfiguration.java index 40c6477f50..38341310fe 100644 --- a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/main/java/org/springframework/cloud/kubernetes/configuration/watcher/RefreshTriggerAutoConfiguration.java +++ b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/main/java/org/springframework/cloud/kubernetes/configuration/watcher/RefreshTriggerAutoConfiguration.java @@ -41,8 +41,8 @@ class RefreshTriggerAutoConfiguration { @ConditionalOnMissingBean @Profile({ AMQP, KAFKA }) BusRefreshTrigger busRefreshTrigger(ApplicationEventPublisher applicationEventPublisher, - BusProperties busProperties) { - return new BusRefreshTrigger(applicationEventPublisher, busProperties.getId()); + BusProperties busProperties, ConfigurationWatcherConfigurationProperties properties) { + return new BusRefreshTrigger(applicationEventPublisher, busProperties.getId(), properties); } @Bean diff --git a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/BusEventBasedConfigMapWatcherChangeDetectorTests.java b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/BusEventBasedConfigMapWatcherChangeDetectorTests.java index d15ef54318..338bc9761c 100644 --- a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/BusEventBasedConfigMapWatcherChangeDetectorTests.java +++ b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/BusEventBasedConfigMapWatcherChangeDetectorTests.java @@ -28,6 +28,8 @@ import org.springframework.cloud.bus.BusProperties; import org.springframework.cloud.bus.event.RefreshRemoteApplicationEvent; +import org.springframework.cloud.bus.event.RemoteApplicationEvent; +import org.springframework.cloud.bus.event.ShutdownRemoteApplicationEvent; import org.springframework.cloud.kubernetes.client.config.KubernetesClientConfigMapPropertySourceLocator; import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider; import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties; @@ -39,6 +41,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.verify; import static org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider.NAMESPACE_PROPERTY; +import static org.springframework.cloud.kubernetes.configuration.watcher.ConfigurationWatcherConfigurationProperties.RefreshStrategy; /** * @author Ryan Baxter @@ -68,31 +71,52 @@ class BusEventBasedConfigMapWatcherChangeDetectorTests { private BusProperties busProperties; + private MockEnvironment mockEnvironment; + @BeforeEach void setup() { - MockEnvironment mockEnvironment = new MockEnvironment(); + mockEnvironment = new MockEnvironment(); mockEnvironment.setProperty(NAMESPACE_PROPERTY, "default"); - ConfigurationWatcherConfigurationProperties configurationWatcherConfigurationProperties = new ConfigurationWatcherConfigurationProperties(); busProperties = new BusProperties(); - changeDetector = new BusEventBasedConfigMapWatcherChangeDetector(coreV1Api, mockEnvironment, - ConfigReloadProperties.DEFAULT, UPDATE_STRATEGY, configMapPropertySourceLocator, - new KubernetesNamespaceProvider(mockEnvironment), configurationWatcherConfigurationProperties, - threadPoolTaskExecutor, new BusRefreshTrigger(applicationEventPublisher, busProperties.getId())); } @Test void triggerRefreshWithConfigMap() { - V1ObjectMeta objectMeta = new V1ObjectMeta(); - objectMeta.setName("foo"); - V1ConfigMap configMap = new V1ConfigMap(); - configMap.setMetadata(objectMeta); - changeDetector.triggerRefresh(configMap, configMap.getMetadata().getName()); ArgumentCaptor argumentCaptor = ArgumentCaptor .forClass(RefreshRemoteApplicationEvent.class); + triggerRefreshWithConfigMap(RefreshStrategy.REFRESH, argumentCaptor); + } + + @Test + void triggerRefreshWithConfigMapUsingShutdown() { + ArgumentCaptor argumentCaptor = ArgumentCaptor + .forClass(ShutdownRemoteApplicationEvent.class); + triggerRefreshWithConfigMap(RefreshStrategy.SHUTDOWN, argumentCaptor); + } + + void triggerRefreshWithConfigMap(RefreshStrategy strategy, + ArgumentCaptor argumentCaptor) { + V1ObjectMeta objectMeta = new V1ObjectMeta(); + objectMeta.setName("foo"); + V1ConfigMap configMap = getV1ConfigMap(objectMeta, strategy); verify(applicationEventPublisher).publishEvent(argumentCaptor.capture()); assertThat(argumentCaptor.getValue().getSource()).isEqualTo(configMap); assertThat(argumentCaptor.getValue().getOriginService()).isEqualTo(busProperties.getId()); assertThat(argumentCaptor.getValue().getDestinationService()).isEqualTo("foo:**"); } + private V1ConfigMap getV1ConfigMap(V1ObjectMeta objectMeta, RefreshStrategy refreshStrategy) { + V1ConfigMap configMap = new V1ConfigMap(); + configMap.setMetadata(objectMeta); + ConfigurationWatcherConfigurationProperties configurationWatcherConfigurationProperties = new ConfigurationWatcherConfigurationProperties(); + configurationWatcherConfigurationProperties.setRefreshStrategy(refreshStrategy); + BusEventBasedConfigMapWatcherChangeDetector changeDetector = new BusEventBasedConfigMapWatcherChangeDetector( + coreV1Api, mockEnvironment, ConfigReloadProperties.DEFAULT, UPDATE_STRATEGY, + configMapPropertySourceLocator, new KubernetesNamespaceProvider(mockEnvironment), + configurationWatcherConfigurationProperties, threadPoolTaskExecutor, new BusRefreshTrigger( + applicationEventPublisher, busProperties.getId(), configurationWatcherConfigurationProperties)); + changeDetector.triggerRefresh(configMap, configMap.getMetadata().getName()); + return configMap; + } + } diff --git a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/BusEventBasedSecretsWatcherChangeDetectorTests.java b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/BusEventBasedSecretsWatcherChangeDetectorTests.java index b79a068b32..3ad8529678 100644 --- a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/BusEventBasedSecretsWatcherChangeDetectorTests.java +++ b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/BusEventBasedSecretsWatcherChangeDetectorTests.java @@ -28,6 +28,7 @@ import org.springframework.cloud.bus.BusProperties; import org.springframework.cloud.bus.event.RefreshRemoteApplicationEvent; +import org.springframework.cloud.bus.event.RemoteApplicationEvent; import org.springframework.cloud.kubernetes.client.config.KubernetesClientSecretsPropertySourceLocator; import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider; import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties; @@ -39,6 +40,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.verify; import static org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider.NAMESPACE_PROPERTY; +import static org.springframework.cloud.kubernetes.configuration.watcher.ConfigurationWatcherConfigurationProperties.RefreshStrategy; /** * @author Ryan Baxter @@ -64,35 +66,55 @@ class BusEventBasedSecretsWatcherChangeDetectorTests { @Mock private ApplicationEventPublisher applicationEventPublisher; - private BusEventBasedSecretsWatcherChangeDetector changeDetector; - private BusProperties busProperties; + private MockEnvironment mockEnvironment; + @BeforeEach void setup() { - MockEnvironment mockEnvironment = new MockEnvironment(); + mockEnvironment = new MockEnvironment(); mockEnvironment.setProperty(NAMESPACE_PROPERTY, "default"); - ConfigurationWatcherConfigurationProperties configurationWatcherConfigurationProperties = new ConfigurationWatcherConfigurationProperties(); busProperties = new BusProperties(); - changeDetector = new BusEventBasedSecretsWatcherChangeDetector(coreV1Api, mockEnvironment, - ConfigReloadProperties.DEFAULT, UPDATE_STRATEGY, secretsPropertySourceLocator, - new KubernetesNamespaceProvider(mockEnvironment), configurationWatcherConfigurationProperties, - threadPoolTaskExecutor, new BusRefreshTrigger(applicationEventPublisher, busProperties.getId())); } @Test void triggerRefreshWithSecret() { - V1ObjectMeta objectMeta = new V1ObjectMeta(); - objectMeta.setName("foo"); - V1Secret secret = new V1Secret(); - secret.setMetadata(objectMeta); - changeDetector.triggerRefresh(secret, secret.getMetadata().getName()); ArgumentCaptor argumentCaptor = ArgumentCaptor .forClass(RefreshRemoteApplicationEvent.class); + triggerRefreshWithSecret(ConfigurationWatcherConfigurationProperties.RefreshStrategy.REFRESH, argumentCaptor); + } + + @Test + void triggerRefreshWithSecretWithShutdown() { + ArgumentCaptor argumentCaptor = ArgumentCaptor + .forClass(RefreshRemoteApplicationEvent.class); + triggerRefreshWithSecret(ConfigurationWatcherConfigurationProperties.RefreshStrategy.REFRESH, argumentCaptor); + } + + void triggerRefreshWithSecret(RefreshStrategy strategy, + ArgumentCaptor argumentCaptor) { + V1ObjectMeta objectMeta = new V1ObjectMeta(); + objectMeta.setName("foo"); + V1Secret secret = getV1Secret(objectMeta, strategy); verify(applicationEventPublisher).publishEvent(argumentCaptor.capture()); assertThat(argumentCaptor.getValue().getSource()).isEqualTo(secret); assertThat(argumentCaptor.getValue().getOriginService()).isEqualTo(busProperties.getId()); assertThat(argumentCaptor.getValue().getDestinationService()).isEqualTo("foo:**"); } + private V1Secret getV1Secret(V1ObjectMeta objectMeta, + ConfigurationWatcherConfigurationProperties.RefreshStrategy refreshStrategy) { + V1Secret secret = new V1Secret(); + secret.setMetadata(objectMeta); + ConfigurationWatcherConfigurationProperties configurationWatcherConfigurationProperties = new ConfigurationWatcherConfigurationProperties(); + configurationWatcherConfigurationProperties.setRefreshStrategy(refreshStrategy); + BusEventBasedSecretsWatcherChangeDetector changeDetector = new BusEventBasedSecretsWatcherChangeDetector( + coreV1Api, mockEnvironment, ConfigReloadProperties.DEFAULT, UPDATE_STRATEGY, + secretsPropertySourceLocator, new KubernetesNamespaceProvider(mockEnvironment), + configurationWatcherConfigurationProperties, threadPoolTaskExecutor, new BusRefreshTrigger( + applicationEventPublisher, busProperties.getId(), configurationWatcherConfigurationProperties)); + changeDetector.triggerRefresh(secret, secret.getMetadata().getName()); + return secret; + } + } diff --git a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/HttpBasedConfigMapWatchChangeDetectorTests.java b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/HttpBasedConfigMapWatchChangeDetectorTests.java index 7c6a3c6b5a..e0b237992a 100644 --- a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/HttpBasedConfigMapWatchChangeDetectorTests.java +++ b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/HttpBasedConfigMapWatchChangeDetectorTests.java @@ -30,6 +30,7 @@ import io.kubernetes.client.openapi.models.V1EndpointAddress; import io.kubernetes.client.openapi.models.V1ObjectMeta; import io.kubernetes.client.util.ClientBuilder; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -58,6 +59,7 @@ import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; import static org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider.NAMESPACE_PROPERTY; +import static org.springframework.cloud.kubernetes.configuration.watcher.ConfigurationWatcherConfigurationProperties.RefreshStrategy; /** * @author Ryan Baxter @@ -83,9 +85,11 @@ class HttpBasedConfigMapWatchChangeDetectorTests { @Mock private KubernetesInformerReactiveDiscoveryClient reactiveDiscoveryClient; - private HttpBasedConfigMapWatchChangeDetector changeDetector; + private MockEnvironment mockEnvironment; - private ConfigurationWatcherConfigurationProperties configurationWatcherConfigurationProperties; + private WebClient webClient; + + private ConfigurationUpdateStrategy strategy; @BeforeAll static void beforeAll() { @@ -105,58 +109,112 @@ static void teardown() { @BeforeEach void setup() { - - MockEnvironment mockEnvironment = new MockEnvironment(); + mockEnvironment = new MockEnvironment(); mockEnvironment.setProperty(NAMESPACE_PROPERTY, "default"); - configurationWatcherConfigurationProperties = new ConfigurationWatcherConfigurationProperties(); - WebClient webClient = WebClient.builder().build(); - - ConfigurationUpdateStrategy strategy = new ConfigurationUpdateStrategy("refresh", () -> { + webClient = WebClient.builder().build(); + strategy = new ConfigurationUpdateStrategy("refresh", () -> { }); + } - changeDetector = new HttpBasedConfigMapWatchChangeDetector(coreV1Api, mockEnvironment, - ConfigReloadProperties.DEFAULT, strategy, configMapPropertySourceLocator, - new KubernetesNamespaceProvider(mockEnvironment), configurationWatcherConfigurationProperties, - threadPoolTaskExecutor, new HttpRefreshTrigger(reactiveDiscoveryClient, - configurationWatcherConfigurationProperties, webClient)); + @Test + void triggerConfigMapRefreshUsingRefresh() { + triggerConfigMapRefresh("/actuator/refresh", RefreshStrategy.REFRESH); } @Test - void triggerConfigMapRefresh() { + void triggerConfigMapRefreshUsingShutdown() { + triggerConfigMapRefresh("/actuator/shutdown", RefreshStrategy.SHUTDOWN); + } + + void triggerConfigMapRefresh(String actuatorPath, RefreshStrategy refreshStrategy) { stubReactiveCall(); V1ConfigMap configMap = new V1ConfigMap(); V1ObjectMeta objectMeta = new V1ObjectMeta(); objectMeta.setName("foo"); configMap.setMetadata(objectMeta); WireMock.configureFor("localhost", WIRE_MOCK_SERVER.port()); - WireMock.stubFor(WireMock.post(WireMock.urlEqualTo("/actuator/refresh")) - .willReturn(WireMock.aResponse().withStatus(200))); + WireMock + .stubFor(WireMock.post(WireMock.urlEqualTo(actuatorPath)).willReturn(WireMock.aResponse().withStatus(200))); + + ConfigurationWatcherConfigurationProperties configurationWatcherConfigurationProperties = new ConfigurationWatcherConfigurationProperties(); + configurationWatcherConfigurationProperties.setRefreshStrategy(refreshStrategy); + HttpBasedConfigMapWatchChangeDetector changeDetector = new HttpBasedConfigMapWatchChangeDetector(coreV1Api, + mockEnvironment, ConfigReloadProperties.DEFAULT, strategy, configMapPropertySourceLocator, + new KubernetesNamespaceProvider(mockEnvironment), configurationWatcherConfigurationProperties, + threadPoolTaskExecutor, new HttpRefreshTrigger(reactiveDiscoveryClient, + configurationWatcherConfigurationProperties, webClient)); StepVerifier.create(changeDetector.triggerRefresh(configMap, configMap.getMetadata().getName())) .verifyComplete(); - WireMock.verify(WireMock.postRequestedFor(WireMock.urlEqualTo("/actuator/refresh"))); + WireMock.verify(WireMock.postRequestedFor(WireMock.urlEqualTo(actuatorPath))); + } + + @Test + void triggerConfigMapRefreshWithPropertiesBasedActuatorPathUsingRefresh() { + triggerConfigMapRefreshWithPropertiesBasedActuatorPath("/refresh", RefreshStrategy.REFRESH); } @Test - void triggerConfigMapRefreshWithPropertiesBasedActuatorPath() { + void triggerConfigMapRefreshWithPropertiesBasedActuatorPathUsingShutdown() { + triggerConfigMapRefreshWithPropertiesBasedActuatorPath("/shutdown", RefreshStrategy.SHUTDOWN); + } + + void triggerConfigMapRefreshWithPropertiesBasedActuatorPath(String endpoint, RefreshStrategy refreshStrategy) { stubReactiveCall(); + ConfigurationWatcherConfigurationProperties configurationWatcherConfigurationProperties = new ConfigurationWatcherConfigurationProperties(); + configurationWatcherConfigurationProperties.setRefreshStrategy(refreshStrategy); configurationWatcherConfigurationProperties.setActuatorPath("/my/custom/actuator"); V1ConfigMap configMap = new V1ConfigMap(); V1ObjectMeta objectMeta = new V1ObjectMeta(); objectMeta.setName("foo"); configMap.setMetadata(objectMeta); WireMock.configureFor("localhost", WIRE_MOCK_SERVER.port()); - WireMock.stubFor(WireMock.post(WireMock.urlEqualTo("/my/custom/actuator/refresh")) + WireMock.stubFor(WireMock.post(WireMock.urlEqualTo("/my/custom/actuator" + endpoint)) .willReturn(WireMock.aResponse().withStatus(200))); + HttpBasedConfigMapWatchChangeDetector changeDetector = new HttpBasedConfigMapWatchChangeDetector(coreV1Api, + mockEnvironment, ConfigReloadProperties.DEFAULT, strategy, configMapPropertySourceLocator, + new KubernetesNamespaceProvider(mockEnvironment), configurationWatcherConfigurationProperties, + threadPoolTaskExecutor, new HttpRefreshTrigger(reactiveDiscoveryClient, + configurationWatcherConfigurationProperties, webClient)); StepVerifier.create(changeDetector.triggerRefresh(configMap, configMap.getMetadata().getName())) .verifyComplete(); - WireMock.verify(WireMock.postRequestedFor(WireMock.urlEqualTo("/my/custom/actuator/refresh"))); + WireMock.verify(WireMock.postRequestedFor(WireMock.urlEqualTo("/my/custom/actuator" + endpoint))); + } + + @Test + void triggerConfigMapRefreshWithAnnotationActuatorPathUsingRefresh() { + triggerConfigMapRefreshWithAnnotationActuatorPath("/refresh", RefreshStrategy.REFRESH); } @Test - void triggerConfigMapRefreshWithAnnotationActuatorPath() { + void triggerConfigMapRefreshWithAnnotationActuatorPathUsingShutdown() { + triggerConfigMapRefreshWithAnnotationActuatorPath("/shutdown", RefreshStrategy.SHUTDOWN); + } + + void triggerConfigMapRefreshWithAnnotationActuatorPath(String endpoint, RefreshStrategy refreshStrategy) { int port = WIRE_MOCK_SERVER.port(); WireMock.configureFor("localhost", port); + List instances = getServiceInstances(port); + when(reactiveDiscoveryClient.getInstances(eq("foo"))).thenReturn(Flux.fromIterable(instances)); + V1ConfigMap configMap = new V1ConfigMap(); + V1ObjectMeta objectMeta = new V1ObjectMeta(); + objectMeta.setName("foo"); + configMap.setMetadata(objectMeta); + WireMock.stubFor(WireMock.post(WireMock.urlEqualTo("/my/custom/actuator" + endpoint)) + .willReturn(WireMock.aResponse().withStatus(200))); + ConfigurationWatcherConfigurationProperties configurationWatcherConfigurationProperties = new ConfigurationWatcherConfigurationProperties(); + configurationWatcherConfigurationProperties.setRefreshStrategy(refreshStrategy); + HttpBasedConfigMapWatchChangeDetector changeDetector = new HttpBasedConfigMapWatchChangeDetector(coreV1Api, + mockEnvironment, ConfigReloadProperties.DEFAULT, strategy, configMapPropertySourceLocator, + new KubernetesNamespaceProvider(mockEnvironment), configurationWatcherConfigurationProperties, + threadPoolTaskExecutor, new HttpRefreshTrigger(reactiveDiscoveryClient, + configurationWatcherConfigurationProperties, webClient)); + StepVerifier.create(changeDetector.triggerRefresh(configMap, configMap.getMetadata().getName())) + .verifyComplete(); + WireMock.verify(WireMock.postRequestedFor(WireMock.urlEqualTo("/my/custom/actuator" + endpoint))); + } + + private static @NotNull List getServiceInstances(int port) { Map metadata = new HashMap<>(); metadata.put(ConfigurationWatcherConfigurationProperties.ANNOTATION_KEY, "http://:" + port + "/my/custom/actuator"); @@ -169,20 +227,10 @@ void triggerConfigMapRefreshWithAnnotationActuatorPath() { DefaultKubernetesServiceInstance fooServiceInstance = new DefaultKubernetesServiceInstance("foo", "foo", fooEndpointAddress.getIp(), fooEndpointPort.getPort(), metadata, false); instances.add(fooServiceInstance); - when(reactiveDiscoveryClient.getInstances(eq("foo"))).thenReturn(Flux.fromIterable(instances)); - V1ConfigMap configMap = new V1ConfigMap(); - V1ObjectMeta objectMeta = new V1ObjectMeta(); - objectMeta.setName("foo"); - configMap.setMetadata(objectMeta); - WireMock.stubFor(WireMock.post(WireMock.urlEqualTo("/my/custom/actuator/refresh")) - .willReturn(WireMock.aResponse().withStatus(200))); - StepVerifier.create(changeDetector.triggerRefresh(configMap, configMap.getMetadata().getName())) - .verifyComplete(); - WireMock.verify(WireMock.postRequestedFor(WireMock.urlEqualTo("/my/custom/actuator/refresh"))); + return instances; } private void stubReactiveCall() { - V1EndpointAddress fooEndpointAddress = new V1EndpointAddress(); fooEndpointAddress.setIp("127.0.0.1"); fooEndpointAddress.setHostname("localhost"); diff --git a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/HttpBasedSecretsWatchChangeDetectorTests.java b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/HttpBasedSecretsWatchChangeDetectorTests.java index 87fae0c7a0..df8f6c7c79 100644 --- a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/HttpBasedSecretsWatchChangeDetectorTests.java +++ b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/HttpBasedSecretsWatchChangeDetectorTests.java @@ -52,12 +52,14 @@ import org.springframework.cloud.kubernetes.commons.discovery.DefaultKubernetesServiceInstance; import org.springframework.mock.env.MockEnvironment; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.client.WebClient; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; import static org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider.NAMESPACE_PROPERTY; +import static org.springframework.cloud.kubernetes.configuration.watcher.ConfigurationWatcherConfigurationProperties.RefreshStrategy; /** * @author Ryan Baxter @@ -86,21 +88,15 @@ class HttpBasedSecretsWatchChangeDetectorTests { @Mock private KubernetesInformerReactiveDiscoveryClient reactiveDiscoveryClient; - private HttpBasedSecretsWatchChangeDetector changeDetector; + private MockEnvironment mockEnvironment; - private ConfigurationWatcherConfigurationProperties configurationWatcherConfigurationProperties; + private WebClient webClient; @BeforeEach void setup() { - MockEnvironment mockEnvironment = new MockEnvironment(); + mockEnvironment = new MockEnvironment(); mockEnvironment.setProperty(NAMESPACE_PROPERTY, "default"); - configurationWatcherConfigurationProperties = new ConfigurationWatcherConfigurationProperties(); - WebClient webClient = WebClient.builder().build(); - changeDetector = new HttpBasedSecretsWatchChangeDetector(coreV1Api, mockEnvironment, - ConfigReloadProperties.DEFAULT, updateStrategy, secretsPropertySourceLocator, - new KubernetesNamespaceProvider(mockEnvironment), configurationWatcherConfigurationProperties, - threadPoolTaskExecutor, new HttpRefreshTrigger(reactiveDiscoveryClient, - configurationWatcherConfigurationProperties, webClient)); + webClient = WebClient.builder().build(); } @BeforeAll @@ -120,36 +116,83 @@ static void teardown() { } @Test - void triggerSecretRefresh() { + void triggerSecretRefreshUsingRefresh() { + triggerSecretRefresh("/refresh", RefreshStrategy.REFRESH); + } + + @Test + void triggerSecretRefreshUsingShutdown() { + triggerSecretRefresh("/shutdown", RefreshStrategy.SHUTDOWN); + } + + void triggerSecretRefresh(String endpoint, RefreshStrategy refreshStrategy) { stubReactiveCall(); V1Secret secret = new V1Secret(); V1ObjectMeta objectMeta = new V1ObjectMeta(); objectMeta.setName("foo"); secret.setMetadata(objectMeta); WireMock.configureFor("localhost", WIRE_MOCK_SERVER.port()); - WireMock.stubFor(WireMock.post(WireMock.urlEqualTo("/actuator/refresh")) + WireMock.stubFor(WireMock.post(WireMock.urlEqualTo("/actuator" + endpoint)) .willReturn(WireMock.aResponse().withStatus(200))); + HttpBasedSecretsWatchChangeDetector changeDetector = getHttpBasedSecretsWatchChangeDetector(refreshStrategy); StepVerifier.create(changeDetector.triggerRefresh(secret, secret.getMetadata().getName())).verifyComplete(); - WireMock.verify(WireMock.postRequestedFor(WireMock.urlEqualTo("/actuator/refresh"))); + WireMock.verify(WireMock.postRequestedFor(WireMock.urlEqualTo("/actuator" + endpoint))); + } + + private HttpBasedSecretsWatchChangeDetector getHttpBasedSecretsWatchChangeDetector( + RefreshStrategy refreshStrategy) { + return getHttpBasedSecretsWatchChangeDetector(null, refreshStrategy); + } + + private HttpBasedSecretsWatchChangeDetector getHttpBasedSecretsWatchChangeDetector(String actuatorPath, + RefreshStrategy refreshStrategy) { + ConfigurationWatcherConfigurationProperties configurationWatcherConfigurationProperties = new ConfigurationWatcherConfigurationProperties(); + if (StringUtils.hasText(actuatorPath)) { + configurationWatcherConfigurationProperties.setActuatorPath(actuatorPath); + } + configurationWatcherConfigurationProperties.setRefreshStrategy(refreshStrategy); + return new HttpBasedSecretsWatchChangeDetector(coreV1Api, mockEnvironment, ConfigReloadProperties.DEFAULT, + updateStrategy, secretsPropertySourceLocator, new KubernetesNamespaceProvider(mockEnvironment), + configurationWatcherConfigurationProperties, threadPoolTaskExecutor, new HttpRefreshTrigger( + reactiveDiscoveryClient, configurationWatcherConfigurationProperties, webClient)); } @Test - void triggerSecretRefreshWithPropertiesBasedActuatorPath() { + void triggerSecretRefreshWithPropertiesBasedActuatorPathUsingRefresh() { + triggerSecretRefreshWithPropertiesBasedActuatorPath("/refresh", RefreshStrategy.REFRESH); + } + + @Test + void triggerSecretRefreshWithPropertiesBasedActuatorPathUsingShutdown() { + triggerSecretRefreshWithPropertiesBasedActuatorPath("/shutdown", RefreshStrategy.SHUTDOWN); + } + + void triggerSecretRefreshWithPropertiesBasedActuatorPath(String endpoint, RefreshStrategy refreshStrategy) { stubReactiveCall(); - configurationWatcherConfigurationProperties.setActuatorPath("/my/custom/actuator"); V1Secret secret = new V1Secret(); V1ObjectMeta objectMeta = new V1ObjectMeta(); objectMeta.setName("foo"); secret.setMetadata(objectMeta); WireMock.configureFor("localhost", WIRE_MOCK_SERVER.port()); - WireMock.stubFor(WireMock.post(WireMock.urlEqualTo("/my/custom/actuator/refresh")) + WireMock.stubFor(WireMock.post(WireMock.urlEqualTo("/my/custom/actuator" + endpoint)) .willReturn(WireMock.aResponse().withStatus(200))); + HttpBasedSecretsWatchChangeDetector changeDetector = getHttpBasedSecretsWatchChangeDetector( + "/my/custom/actuator", refreshStrategy); StepVerifier.create(changeDetector.triggerRefresh(secret, secret.getMetadata().getName())).verifyComplete(); - WireMock.verify(WireMock.postRequestedFor(WireMock.urlEqualTo("/my/custom/actuator/refresh"))); + WireMock.verify(WireMock.postRequestedFor(WireMock.urlEqualTo("/my/custom/actuator" + endpoint))); } @Test - void triggerSecretRefreshWithAnnotationActuatorPath() { + void triggerSecretRefreshWithAnnotationActuatorPathUsingRefresh() { + triggerSecretRefreshWithAnnotationActuatorPath("/refresh", RefreshStrategy.REFRESH); + } + + @Test + void triggerSecretRefreshWithAnnotationActuatorPathUsingShutdown() { + triggerSecretRefreshWithAnnotationActuatorPath("/shutdown", RefreshStrategy.SHUTDOWN); + } + + void triggerSecretRefreshWithAnnotationActuatorPath(String endpoint, RefreshStrategy refreshStrategy) { WireMock.configureFor("localhost", WIRE_MOCK_SERVER.port()); Map metadata = new HashMap<>(); metadata.put(ConfigurationWatcherConfigurationProperties.ANNOTATION_KEY, @@ -168,10 +211,12 @@ void triggerSecretRefreshWithAnnotationActuatorPath() { V1ObjectMeta objectMeta = new V1ObjectMeta(); objectMeta.setName("foo"); secret.setMetadata(objectMeta); - WireMock.stubFor(WireMock.post(WireMock.urlEqualTo("/my/custom/actuator/refresh")) + WireMock.stubFor(WireMock.post(WireMock.urlEqualTo("/my/custom/actuator" + endpoint)) .willReturn(WireMock.aResponse().withStatus(200))); + HttpBasedSecretsWatchChangeDetector changeDetector = getHttpBasedSecretsWatchChangeDetector( + "/my/custom/actuator", refreshStrategy); StepVerifier.create(changeDetector.triggerRefresh(secret, secret.getMetadata().getName())).verifyComplete(); - WireMock.verify(WireMock.postRequestedFor(WireMock.urlEqualTo("/my/custom/actuator/refresh"))); + WireMock.verify(WireMock.postRequestedFor(WireMock.urlEqualTo("/my/custom/actuator" + endpoint))); } private void stubReactiveCall() { diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/ActuatorRefreshIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/ActuatorRefreshIT.java index 26f7301c1b..52ed7f2727 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/ActuatorRefreshIT.java +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/ActuatorRefreshIT.java @@ -126,6 +126,32 @@ void testActuatorRefresh() { } + void testActuatorShutdown() { + TestUtil.patchForShutdownRefresh(SPRING_CLOUD_K8S_CONFIG_WATCHER_APP_NAME, NAMESPACE, DOCKER_IMAGE); + WireMock.configureFor(WIREMOCK_HOST, WIREMOCK_PORT); + await().timeout(Duration.ofSeconds(60)) + .ignoreException(SocketTimeoutException.class) + .until(() -> WireMock + .stubFor(WireMock.post(WireMock.urlEqualTo("/actuator/shutdown")) + .willReturn(WireMock.aResponse().withBody("{}").withStatus(200))) + .getResponse() + .wasConfigured()); + + createConfigMap(); + + // Wait a bit before we verify + await().atMost(Duration.ofSeconds(30)) + .until(() -> !WireMock.findAll(WireMock.postRequestedFor(WireMock.urlEqualTo("/actuator/shutdown"))) + .isEmpty()); + WireMock.verify(WireMock.postRequestedFor(WireMock.urlEqualTo("/actuator/shutdown"))); + + deleteConfigMap(); + + // the other test + testActuatorRefreshReloadDisabled(); + + } + /* * same test as above, but reload is disabled. */ diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/TestUtil.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/TestUtil.java index b9d77740a8..6a21ff68e4 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/TestUtil.java +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-configuration-watcher/src/test/java/org/springframework/cloud/kubernetes/configuration/watcher/TestUtil.java @@ -61,8 +61,41 @@ private TestUtil() { } """; + private static final String USE_SHUTDOWN = """ + { + "spec": { + "template": { + "spec": { + "containers": [{ + "name": "spring-cloud-kubernetes-configuration-watcher", + "image": "image_name_here", + "env": [ + { + "name": "LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_KUBERNETES_COMMONS_CONFIG_RELOAD", + "value": "DEBUG" + }, + { + "name": "LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_KUBERNETES_CONFIGURATION_WATCHER", + "value": "DEBUG" + }, + { + "name": "SPRING_CLOUD_KUBERNETES_CONFIGURATION_WATCHER_REFRESH_STRATEGY", + "value": "shutdown" + } + ] + }] + } + } + } + } + """; + static void patchForDisabledReload(String deploymentName, String namespace, String imageName) { patchWithReplace(imageName, deploymentName, namespace, BODY_ONE, POD_LABELS); } + static void patchForShutdownRefresh(String deploymentName, String namespace, String imageName) { + patchWithReplace(imageName, deploymentName, namespace, USE_SHUTDOWN, POD_LABELS); + } + } From 5536b637d09a523ad0dd7bf9029681a47b7e36cb Mon Sep 17 00:00:00 2001 From: spring-builds Date: Fri, 22 Nov 2024 13:23:03 +0000 Subject: [PATCH 06/16] Bumping versions --- docs/modules/ROOT/partials/_configprops.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/ROOT/partials/_configprops.adoc b/docs/modules/ROOT/partials/_configprops.adoc index aefc12a77d..587592c11c 100644 --- a/docs/modules/ROOT/partials/_configprops.adoc +++ b/docs/modules/ROOT/partials/_configprops.adoc @@ -58,7 +58,7 @@ |spring.cloud.kubernetes.config.retry | | |spring.cloud.kubernetes.config.sources | | |spring.cloud.kubernetes.config.use-name-as-prefix | `+++false+++` | -|spring.cloud.kubernetes.discovery.all-namespaces | `+++false+++` | if discover is enabled for all namespaces +|spring.cloud.kubernetes.discovery.all-namespaces | `+++false+++` | if discovery is enabled for all namespaces |spring.cloud.kubernetes.discovery.cache-loading-timeout-seconds | `+++60+++` | timeout for initializing discovery cache, will abort the application if exceeded. |spring.cloud.kubernetes.discovery.discovery-server-url | | |spring.cloud.kubernetes.discovery.enabled | `+++true+++` | if kubernetes discovery is enabled From b47f91dcf0f6e25cf24ac304c3c6c7658140cde8 Mon Sep 17 00:00:00 2001 From: erabii Date: Fri, 22 Nov 2024 15:45:57 +0200 Subject: [PATCH 07/16] Fix 1592 empty source on error (#1792) --- ...entConfigMapErrorOnReadingSourceTests.java | 378 ++++++++++++++++++ ...ientSecretsPropertySourceLocatorTests.java | 70 +++- ...netesClientSecretsPropertySourceTests.java | 70 +++- ...ientEventBasedConfigMapChangeDetector.java | 45 +++ ...ClientEventBasedSecretsChangeDetector.java | 46 +++ .../reload_it/EventReloadConfigMapTest.java | 255 ++++++++++++ .../reload_it/EventReloadSecretTest.java | 260 ++++++++++++ .../reload_it/PollingReloadConfigMapTest.java | 234 +++++++++++ .../reload_it/PollingReloadSecretTest.java | 240 +++++++++++ .../kubernetes/commons/config/Constants.java | 6 + .../commons/config/LabeledSourceData.java | 8 + .../commons/config/NamedSourceData.java | 4 + .../config/reload/ConfigReloadUtil.java | 12 +- .../config/reload/ConfigReloadUtilTests.java | 11 + ...ic8ConfigMapErrorOnReadingSourceTests.java | 291 ++++++++++++++ ...abric8SecretErrorOnReadingSourceTests.java | 288 +++++++++++++ ...Fabric8ConfigMapPropertySourceLocator.java | 36 ++ ...leFabric8SecretsPropertySourceLocator.java | 36 ++ .../reload_it/EventReloadConfigMapTest.java | 215 ++++++++++ .../reload_it/EventReloadSecretTest.java | 222 ++++++++++ .../reload_it/PollingReloadConfigMapTest.java | 205 ++++++++++ .../reload_it/PollingReloadSecretTest.java | 212 ++++++++++ 22 files changed, 3098 insertions(+), 46 deletions(-) create mode 100644 spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientConfigMapErrorOnReadingSourceTests.java create mode 100644 spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/VisibleKubernetesClientEventBasedConfigMapChangeDetector.java create mode 100644 spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/VisibleKubernetesClientEventBasedSecretsChangeDetector.java create mode 100644 spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/EventReloadConfigMapTest.java create mode 100644 spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/EventReloadSecretTest.java create mode 100644 spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/PollingReloadConfigMapTest.java create mode 100644 spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/PollingReloadSecretTest.java create mode 100644 spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/Fabric8ConfigMapErrorOnReadingSourceTests.java create mode 100644 spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/Fabric8SecretErrorOnReadingSourceTests.java create mode 100644 spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/VisibleFabric8ConfigMapPropertySourceLocator.java create mode 100644 spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/VisibleFabric8SecretsPropertySourceLocator.java create mode 100644 spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/EventReloadConfigMapTest.java create mode 100644 spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/EventReloadSecretTest.java create mode 100644 spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/PollingReloadConfigMapTest.java create mode 100644 spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/PollingReloadSecretTest.java diff --git a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientConfigMapErrorOnReadingSourceTests.java b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientConfigMapErrorOnReadingSourceTests.java new file mode 100644 index 0000000000..2c79b63c80 --- /dev/null +++ b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientConfigMapErrorOnReadingSourceTests.java @@ -0,0 +1,378 @@ +/* + * Copyright 2013-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.client.config; + +import java.util.List; +import java.util.Map; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.Configuration; +import io.kubernetes.client.openapi.JSON; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1ConfigMapBuilder; +import io.kubernetes.client.openapi.models.V1ConfigMapList; +import io.kubernetes.client.openapi.models.V1ObjectMetaBuilder; +import io.kubernetes.client.util.ClientBuilder; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider; +import org.springframework.cloud.kubernetes.commons.config.ConfigMapConfigProperties; +import org.springframework.cloud.kubernetes.commons.config.Constants; +import org.springframework.cloud.kubernetes.commons.config.RetryProperties; +import org.springframework.core.env.CompositePropertySource; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.PropertySource; +import org.springframework.mock.env.MockEnvironment; + +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.assertj.core.api.Assertions.assertThat; + +/** + * @author wind57 + */ +@ExtendWith(OutputCaptureExtension.class) +class KubernetesClientConfigMapErrorOnReadingSourceTests { + + private static final V1ConfigMapList SINGLE_CONFIGMAP_LIST = new V1ConfigMapList() + .addItemsItem(new V1ConfigMapBuilder() + .withMetadata( + new V1ObjectMetaBuilder().withName("two").withNamespace("default").withResourceVersion("1").build()) + .build()); + + private static final V1ConfigMapList DOUBLE_CONFIGMAP_LIST = new V1ConfigMapList() + .addItemsItem(new V1ConfigMapBuilder() + .withMetadata( + new V1ObjectMetaBuilder().withName("one").withNamespace("default").withResourceVersion("1").build()) + .build()) + .addItemsItem(new V1ConfigMapBuilder() + .withMetadata( + new V1ObjectMetaBuilder().withName("two").withNamespace("default").withResourceVersion("1").build()) + .build()); + + private static WireMockServer wireMockServer; + + @BeforeAll + public static void setup() { + wireMockServer = new WireMockServer(options().dynamicPort()); + + wireMockServer.start(); + WireMock.configureFor("localhost", wireMockServer.port()); + + ApiClient client = new ClientBuilder().setBasePath("http://localhost:" + wireMockServer.port()).build(); + client.setDebugging(true); + Configuration.setDefaultApiClient(client); + } + + @AfterAll + public static void after() { + wireMockServer.stop(); + } + + @AfterEach + public void afterEach() { + WireMock.reset(); + } + + /** + *
+	 *     we try to read all config maps in a namespace and fail,
+	 *     thus generate a well defined name for the source.
+	 * 
+ */ + @Test + void namedSingleConfigMapFails() { + String name = "my-config"; + String namespace = "spring-k8s"; + String path = "/api/v1/namespaces/" + namespace + "/configmaps"; + + stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error"))); + + ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(), List.of(), + Map.of(), true, name, namespace, false, true, false, RetryProperties.DEFAULT); + + CoreV1Api api = new CoreV1Api(); + KubernetesClientConfigMapPropertySourceLocator locator = new KubernetesClientConfigMapPropertySourceLocator(api, + configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment())); + + CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment()); + MapPropertySource mapPropertySource = (MapPropertySource) propertySource.getPropertySources() + .stream() + .findAny() + .orElseThrow(); + + assertThat(mapPropertySource.getName()).isEqualTo("configmap..spring-k8s"); + assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true"); + + } + + /** + *
+	 *     there are two sources and we try to read them.
+	 *     one fails and one passes.
+	 * 
+ */ + @Test + void namedTwoConfigMapsOneFails(CapturedOutput output) { + String configMapNameOne = "one"; + String configMapNameTwo = "two"; + String namespace = "default"; + String path = "/api/v1/namespaces/default/configmaps"; + + stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error")) + .inScenario("started") + .willSetStateTo("go-to-next")); + + stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(SINGLE_CONFIGMAP_LIST))) + .inScenario("started") + .whenScenarioStateIs("go-to-next") + .willSetStateTo("done")); + + ConfigMapConfigProperties.Source sourceOne = new ConfigMapConfigProperties.Source(configMapNameOne, namespace, + Map.of(), null, null, null); + ConfigMapConfigProperties.Source sourceTwo = new ConfigMapConfigProperties.Source(configMapNameTwo, namespace, + Map.of(), null, null, null); + + ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(), + List.of(sourceOne, sourceTwo), Map.of(), true, null, namespace, false, true, false, + RetryProperties.DEFAULT); + + CoreV1Api api = new CoreV1Api(); + KubernetesClientConfigMapPropertySourceLocator locator = new KubernetesClientConfigMapPropertySourceLocator(api, + configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment())); + + CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment()); + List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList(); + + // two sources are present, one being empty + assertThat(names).containsExactly("configmap.two.default", "configmap..default"); + assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true"); + assertThat(output.getOut()) + .doesNotContain("sourceName : two was requested, but not found in namespace : default"); + + } + + /** + *
+	 *     there are two sources and we try to read them.
+	 *     both fail.
+	 * 
+ */ + @Test + void namedTwoConfigMapsBothFail(CapturedOutput output) { + String configMapNameOne = "one"; + String configMapNameTwo = "two"; + String namespace = "default"; + String path = "/api/v1/namespaces/default/configmaps"; + + stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error")) + .inScenario("started") + .willSetStateTo("go-to-next")); + + stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error")) + .inScenario("started") + .whenScenarioStateIs("go-to-next") + .willSetStateTo("done")); + + ConfigMapConfigProperties.Source sourceOne = new ConfigMapConfigProperties.Source(configMapNameOne, namespace, + Map.of(), null, null, null); + ConfigMapConfigProperties.Source sourceTwo = new ConfigMapConfigProperties.Source(configMapNameTwo, namespace, + Map.of(), null, null, null); + + ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(), + List.of(sourceOne, sourceTwo), Map.of(), true, null, namespace, false, true, false, + RetryProperties.DEFAULT); + + CoreV1Api api = new CoreV1Api(); + KubernetesClientConfigMapPropertySourceLocator locator = new KubernetesClientConfigMapPropertySourceLocator(api, + configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment())); + + CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment()); + List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList(); + + assertThat(names).containsExactly("configmap..default"); + assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true"); + assertThat(output.getOut()) + .doesNotContain("sourceName : one was requested, but not found in namespace : default"); + assertThat(output.getOut()) + .doesNotContain("sourceName : two was requested, but not found in namespace : default"); + } + + /** + *
+	 *     we try to read all config maps in a namespace and fail,
+	 *     thus generate a well defined name for the source.
+	 * 
+ */ + @Test + void labeledSingleConfigMapFails(CapturedOutput output) { + Map labels = Map.of("a", "b"); + String namespace = "spring-k8s"; + String path = "/api/v1/namespaces/" + namespace + "/configmaps"; + + // one for the 'application' named configmap + // the other for the labeled config map + stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error")) + .inScenario("started") + .willSetStateTo("go-to-next")); + + stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error")) + .inScenario("started") + .whenScenarioStateIs("go-to-next") + .willSetStateTo("done")); + + ConfigMapConfigProperties.Source configMapSource = new ConfigMapConfigProperties.Source(null, namespace, labels, + null, null, null); + + ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(), + List.of(configMapSource), labels, true, null, namespace, false, true, false, RetryProperties.DEFAULT); + + CoreV1Api api = new CoreV1Api(); + KubernetesClientConfigMapPropertySourceLocator locator = new KubernetesClientConfigMapPropertySourceLocator(api, + configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment())); + + CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment()); + List sourceNames = propertySource.getPropertySources().stream().map(PropertySource::getName).toList(); + + assertThat(sourceNames).containsExactly("configmap..spring-k8s"); + assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true"); + assertThat(output).contains("failure in reading labeled sources"); + assertThat(output).contains("failure in reading named sources"); + } + + /** + *
+	 *     there are two sources and we try to read them.
+	 *     one fails and one passes.
+	 * 
+ */ + @Test + void labeledTwoConfigMapsOneFails(CapturedOutput output) { + + Map configMapOneLabels = Map.of("one", "1"); + Map configMapTwoLabels = Map.of("two", "2"); + + String namespace = "default"; + String path = "/api/v1/namespaces/default/configmaps"; + + // one for 'application' named configmap and one for the first labeled configmap + stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error")) + .inScenario("started") + .willSetStateTo("first")); + + stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error")) + .inScenario("started") + .whenScenarioStateIs("first") + .willSetStateTo("second")); + + // one that passes + stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(DOUBLE_CONFIGMAP_LIST))) + .inScenario("started") + .whenScenarioStateIs("second") + .willSetStateTo("done")); + + ConfigMapConfigProperties.Source sourceOne = new ConfigMapConfigProperties.Source(null, namespace, + configMapOneLabels, null, null, null); + ConfigMapConfigProperties.Source sourceTwo = new ConfigMapConfigProperties.Source(null, namespace, + configMapTwoLabels, null, null, null); + + ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(), + List.of(sourceOne, sourceTwo), Map.of("one", "1", "two", "2"), true, null, namespace, false, true, + false, RetryProperties.DEFAULT); + + CoreV1Api api = new CoreV1Api(); + KubernetesClientConfigMapPropertySourceLocator locator = new KubernetesClientConfigMapPropertySourceLocator(api, + configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment())); + + CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment()); + List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList(); + + // two sources are present, one being empty + assertThat(names).containsExactly("configmap.two.default", "configmap..default"); + assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true"); + + assertThat(output).contains("failure in reading labeled sources"); + assertThat(output).contains("failure in reading named sources"); + + } + + /** + *
+	 *     there are two sources and we try to read them.
+	 *     both fail.
+	 * 
+ */ + @Test + void labeledTwoConfigMapsBothFail(CapturedOutput output) { + + Map configMapOneLabels = Map.of("one", "1"); + Map configMapTwoLabels = Map.of("two", "2"); + + String namespace = "default"; + String path = "/api/v1/namespaces/default/configmaps"; + + // one for 'application' named configmap and two for the labeled configmaps + stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error")) + .inScenario("started") + .willSetStateTo("first")); + + stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error")) + .inScenario("started") + .whenScenarioStateIs("first") + .willSetStateTo("second")); + + stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error")) + .inScenario("started") + .whenScenarioStateIs("second") + .willSetStateTo("done")); + + ConfigMapConfigProperties.Source sourceOne = new ConfigMapConfigProperties.Source(null, namespace, + configMapOneLabels, null, null, null); + ConfigMapConfigProperties.Source sourceTwo = new ConfigMapConfigProperties.Source(null, namespace, + configMapTwoLabels, null, null, null); + + ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(), + List.of(sourceOne, sourceTwo), Map.of("one", "1", "two", "2"), true, null, namespace, false, true, + false, RetryProperties.DEFAULT); + + CoreV1Api api = new CoreV1Api(); + KubernetesClientConfigMapPropertySourceLocator locator = new KubernetesClientConfigMapPropertySourceLocator(api, + configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment())); + + CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment()); + List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList(); + + // all 3 sources ('application' named source, and two labeled sources) + assertThat(names).containsExactly("configmap..default"); + assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true"); + + assertThat(output).contains("failure in reading labeled sources"); + assertThat(output).contains("failure in reading named sources"); + + } + +} diff --git a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientSecretsPropertySourceLocatorTests.java b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientSecretsPropertySourceLocatorTests.java index b9ecb61cc0..5bcf6ca005 100644 --- a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientSecretsPropertySourceLocatorTests.java +++ b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientSecretsPropertySourceLocatorTests.java @@ -54,28 +54,54 @@ class KubernetesClientSecretsPropertySourceLocatorTests { private static final String LIST_API = "/api/v1/namespaces/default/secrets"; - private static final String LIST_BODY = "{\n" + "\t\"kind\": \"SecretList\",\n" + "\t\"apiVersion\": \"v1\",\n" - + "\t\"metadata\": {\n" + "\t\t\"selfLink\": \"/api/v1/secrets\",\n" - + "\t\t\"resourceVersion\": \"163035\"\n" + "\t},\n" + "\t\"items\": [{\n" + "\t\t\t\"metadata\": {\n" - + "\t\t\t\t\"name\": \"db-secret\",\n" + "\t\t\t\t\"namespace\": \"default\",\n" - + "\t\t\t\t\"selfLink\": \"/api/v1/namespaces/default/secrets/db-secret\",\n" - + "\t\t\t\t\"uid\": \"59ba8e6a-a2d4-416c-b016-22597c193f23\",\n" - + "\t\t\t\t\"resourceVersion\": \"1462\",\n" + "\t\t\t\t\"creationTimestamp\": \"2020-10-28T14:45:02Z\",\n" - + "\t\t\t\t\"labels\": {\n" + "\t\t\t\t\t\"spring.cloud.kubernetes.secret\": \"true\"\n" + "\t\t\t\t}\n" - + "\t\t\t},\n" + "\t\t\t\"data\": {\n" + "\t\t\t\t\"password\": \"cDQ1NXcwcmQ=\",\n" - + "\t\t\t\t\"username\": \"dXNlcg==\"\n" + "\t\t\t},\n" + "\t\t\t\"type\": \"Opaque\"\n" + "\t\t},\n" - + "\t\t{\n" + "\t\t\t\"metadata\": {\n" + "\t\t\t\t\"name\": \"rabbit-password\",\n" - + "\t\t\t\t\"namespace\": \"default\",\n" - + "\t\t\t\t\"selfLink\": \"/api/v1/namespaces/default/secrets/rabbit-password\",\n" - + "\t\t\t\t\"uid\": \"bc211cb4-e7ff-4556-b26e-c54911301740\",\n" - + "\t\t\t\t\"resourceVersion\": \"162708\",\n" - + "\t\t\t\t\"creationTimestamp\": \"2020-10-29T19:47:36Z\",\n" + "\t\t\t\t\"labels\": {\n" - + "\t\t\t\t\t\"spring.cloud.kubernetes.secret\": \"true\"\n" + "\t\t\t\t},\n" - + "\t\t\t\t\"annotations\": {\n" - + "\t\t\t\t\t\"kubectl.kubernetes.io/last-applied-configuration\": \"{\\\"apiVersion\\\":\\\"v1\\\",\\\"data\\\":{\\\"spring.rabbitmq.password\\\":\\\"password\\\"},\\\"kind\\\":\\\"Secret\\\",\\\"metadata\\\":{\\\"annotations\\\":{},\\\"labels\\\":{\\\"spring.cloud.kubernetes.secret\\\":\\\"true\\\"},\\\"name\\\":\\\"rabbit-password\\\",\\\"namespace\\\":\\\"default\\\"},\\\"type\\\":\\\"Opaque\\\"}\\n\"\n" - + "\t\t\t\t}\n" + "\t\t\t},\n" + "\t\t\t\"data\": {\n" - + "\t\t\t\t\"spring.rabbitmq.password\": \"cGFzc3dvcmQ=\"\n" + "\t\t\t},\n" + "\t\t\t\"type\": \"Opaque\"\n" - + "\t\t}\n" + "\t]\n" + "}"; + private static final String LIST_BODY = """ + { + \t"kind": "SecretList", + \t"apiVersion": "v1", + \t"metadata": { + \t\t"selfLink": "/api/v1/secrets", + \t\t"resourceVersion": "163035" + \t}, + \t"items": [{ + \t\t\t"metadata": { + \t\t\t\t"name": "db-secret", + \t\t\t\t"namespace": "default", + \t\t\t\t"selfLink": "/api/v1/namespaces/default/secrets/db-secret", + \t\t\t\t"uid": "59ba8e6a-a2d4-416c-b016-22597c193f23", + \t\t\t\t"resourceVersion": "1462", + \t\t\t\t"creationTimestamp": "2020-10-28T14:45:02Z", + \t\t\t\t"labels": { + \t\t\t\t\t"spring.cloud.kubernetes.secret": "true" + \t\t\t\t} + \t\t\t}, + \t\t\t"data": { + \t\t\t\t"password": "cDQ1NXcwcmQ=", + \t\t\t\t"username": "dXNlcg==" + \t\t\t}, + \t\t\t"type": "Opaque" + \t\t}, + \t\t{ + \t\t\t"metadata": { + \t\t\t\t"name": "rabbit-password", + \t\t\t\t"namespace": "default", + \t\t\t\t"selfLink": "/api/v1/namespaces/default/secrets/rabbit-password", + \t\t\t\t"uid": "bc211cb4-e7ff-4556-b26e-c54911301740", + \t\t\t\t"resourceVersion": "162708", + \t\t\t\t"creationTimestamp": "2020-10-29T19:47:36Z", + \t\t\t\t"labels": { + \t\t\t\t\t"spring.cloud.kubernetes.secret": "true" + \t\t\t\t}, + \t\t\t\t"annotations": { + \t\t\t\t\t"kubectl.kubernetes.io/last-applied-configuration": "{\\"apiVersion\\":\\"v1\\",\\"data\\":{\\"spring.rabbitmq.password\\":\\"password\\"},\\"kind\\":\\"Secret\\",\\"metadata\\":{\\"annotations\\":{},\\"labels\\":{\\"spring.cloud.kubernetes.secret\\":\\"true\\"},\\"name\\":\\"rabbit-password\\",\\"namespace\\":\\"default\\"},\\"type\\":\\"Opaque\\"}\\n" + \t\t\t\t} + \t\t\t}, + \t\t\t"data": { + \t\t\t\t"spring.rabbitmq.password": "cGFzc3dvcmQ=" + \t\t\t}, + \t\t\t"type": "Opaque" + \t\t} + \t] + }"""; private static WireMockServer wireMockServer; diff --git a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientSecretsPropertySourceTests.java b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientSecretsPropertySourceTests.java index 4cc2b8d231..50173d3918 100644 --- a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientSecretsPropertySourceTests.java +++ b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientSecretsPropertySourceTests.java @@ -80,28 +80,54 @@ class KubernetesClientSecretsPropertySourceTests { private static final String LIST_API_WITH_LABEL = "/api/v1/namespaces/default/secrets"; - private static final String LIST_BODY = "{\n" + "\t\"kind\": \"SecretList\",\n" + "\t\"apiVersion\": \"v1\",\n" - + "\t\"metadata\": {\n" + "\t\t\"selfLink\": \"/api/v1/secrets\",\n" - + "\t\t\"resourceVersion\": \"163035\"\n" + "\t},\n" + "\t\"items\": [{\n" + "\t\t\t\"metadata\": {\n" - + "\t\t\t\t\"name\": \"db-secret\",\n" + "\t\t\t\t\"namespace\": \"default\",\n" - + "\t\t\t\t\"selfLink\": \"/api/v1/namespaces/default/secrets/db-secret\",\n" - + "\t\t\t\t\"uid\": \"59ba8e6a-a2d4-416c-b016-22597c193f23\",\n" - + "\t\t\t\t\"resourceVersion\": \"1462\",\n" + "\t\t\t\t\"creationTimestamp\": \"2020-10-28T14:45:02Z\",\n" - + "\t\t\t\t\"labels\": {\n" + "\t\t\t\t\t\"spring.cloud.kubernetes.secret\": \"true\"\n" + "\t\t\t\t}\n" - + "\t\t\t},\n" + "\t\t\t\"data\": {\n" + "\t\t\t\t\"password\": \"cDQ1NXcwcmQ=\",\n" - + "\t\t\t\t\"username\": \"dXNlcg==\"\n" + "\t\t\t},\n" + "\t\t\t\"type\": \"Opaque\"\n" + "\t\t},\n" - + "\t\t{\n" + "\t\t\t\"metadata\": {\n" + "\t\t\t\t\"name\": \"rabbit-password\",\n" - + "\t\t\t\t\"namespace\": \"default\",\n" - + "\t\t\t\t\"selfLink\": \"/api/v1/namespaces/default/secrets/rabbit-password\",\n" - + "\t\t\t\t\"uid\": \"bc211cb4-e7ff-4556-b26e-c54911301740\",\n" - + "\t\t\t\t\"resourceVersion\": \"162708\",\n" - + "\t\t\t\t\"creationTimestamp\": \"2020-10-29T19:47:36Z\",\n" + "\t\t\t\t\"labels\": {\n" - + "\t\t\t\t\t\"spring.cloud.kubernetes.secret\": \"true\"\n" + "\t\t\t\t},\n" - + "\t\t\t\t\"annotations\": {\n" - + "\t\t\t\t\t\"kubectl.kubernetes.io/last-applied-configuration\": \"{\\\"apiVersion\\\":\\\"v1\\\",\\\"data\\\":{\\\"spring.rabbitmq.password\\\":\\\"password\\\"},\\\"kind\\\":\\\"Secret\\\",\\\"metadata\\\":{\\\"annotations\\\":{},\\\"labels\\\":{\\\"spring.cloud.kubernetes.secret\\\":\\\"true\\\"},\\\"name\\\":\\\"rabbit-password\\\",\\\"namespace\\\":\\\"default\\\"},\\\"type\\\":\\\"Opaque\\\"}\\n\"\n" - + "\t\t\t\t}\n" + "\t\t\t},\n" + "\t\t\t\"data\": {\n" - + "\t\t\t\t\"spring.rabbitmq.password\": \"cGFzc3dvcmQ=\"\n" + "\t\t\t},\n" + "\t\t\t\"type\": \"Opaque\"\n" - + "\t\t}\n" + "\t]\n" + "}"; + private static final String LIST_BODY = """ + { + \t"kind": "SecretList", + \t"apiVersion": "v1", + \t"metadata": { + \t\t"selfLink": "/api/v1/secrets", + \t\t"resourceVersion": "163035" + \t}, + \t"items": [{ + \t\t\t"metadata": { + \t\t\t\t"name": "db-secret", + \t\t\t\t"namespace": "default", + \t\t\t\t"selfLink": "/api/v1/namespaces/default/secrets/db-secret", + \t\t\t\t"uid": "59ba8e6a-a2d4-416c-b016-22597c193f23", + \t\t\t\t"resourceVersion": "1462", + \t\t\t\t"creationTimestamp": "2020-10-28T14:45:02Z", + \t\t\t\t"labels": { + \t\t\t\t\t"spring.cloud.kubernetes.secret": "true" + \t\t\t\t} + \t\t\t}, + \t\t\t"data": { + \t\t\t\t"password": "cDQ1NXcwcmQ=", + \t\t\t\t"username": "dXNlcg==" + \t\t\t}, + \t\t\t"type": "Opaque" + \t\t}, + \t\t{ + \t\t\t"metadata": { + \t\t\t\t"name": "rabbit-password", + \t\t\t\t"namespace": "default", + \t\t\t\t"selfLink": "/api/v1/namespaces/default/secrets/rabbit-password", + \t\t\t\t"uid": "bc211cb4-e7ff-4556-b26e-c54911301740", + \t\t\t\t"resourceVersion": "162708", + \t\t\t\t"creationTimestamp": "2020-10-29T19:47:36Z", + \t\t\t\t"labels": { + \t\t\t\t\t"spring.cloud.kubernetes.secret": "true" + \t\t\t\t}, + \t\t\t\t"annotations": { + \t\t\t\t\t"kubectl.kubernetes.io/last-applied-configuration": "{\\"apiVersion\\":\\"v1\\",\\"data\\":{\\"spring.rabbitmq.password\\":\\"password\\"},\\"kind\\":\\"Secret\\",\\"metadata\\":{\\"annotations\\":{},\\"labels\\":{\\"spring.cloud.kubernetes.secret\\":\\"true\\"},\\"name\\":\\"rabbit-password\\",\\"namespace\\":\\"default\\"},\\"type\\":\\"Opaque\\"}\\n" + \t\t\t\t} + \t\t\t}, + \t\t\t"data": { + \t\t\t\t"spring.rabbitmq.password": "cGFzc3dvcmQ=" + \t\t\t}, + \t\t\t"type": "Opaque" + \t\t} + \t] + }"""; private static WireMockServer wireMockServer; diff --git a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/VisibleKubernetesClientEventBasedConfigMapChangeDetector.java b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/VisibleKubernetesClientEventBasedConfigMapChangeDetector.java new file mode 100644 index 0000000000..3045521363 --- /dev/null +++ b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/VisibleKubernetesClientEventBasedConfigMapChangeDetector.java @@ -0,0 +1,45 @@ +/* + * Copyright 2013-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.kubernetes.client.config; + +import io.kubernetes.client.common.KubernetesObject; +import io.kubernetes.client.openapi.apis.CoreV1Api; + +import org.springframework.cloud.kubernetes.client.config.reload.KubernetesClientEventBasedConfigMapChangeDetector; +import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider; +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties; +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy; +import org.springframework.core.env.ConfigurableEnvironment; + +/** + * @author wind57 + */ +public class VisibleKubernetesClientEventBasedConfigMapChangeDetector + extends KubernetesClientEventBasedConfigMapChangeDetector { + + public VisibleKubernetesClientEventBasedConfigMapChangeDetector(CoreV1Api coreV1Api, + ConfigurableEnvironment environment, ConfigReloadProperties properties, + ConfigurationUpdateStrategy strategy, KubernetesClientConfigMapPropertySourceLocator propertySourceLocator, + KubernetesNamespaceProvider kubernetesNamespaceProvider) { + super(coreV1Api, environment, properties, strategy, propertySourceLocator, kubernetesNamespaceProvider); + } + + public void onEvent(KubernetesObject kubernetesObject) { + super.onEvent(kubernetesObject); + } + +} diff --git a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/VisibleKubernetesClientEventBasedSecretsChangeDetector.java b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/VisibleKubernetesClientEventBasedSecretsChangeDetector.java new file mode 100644 index 0000000000..8cc7540e35 --- /dev/null +++ b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/VisibleKubernetesClientEventBasedSecretsChangeDetector.java @@ -0,0 +1,46 @@ +/* + * Copyright 2013-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.kubernetes.client.config; + +import io.kubernetes.client.common.KubernetesObject; +import io.kubernetes.client.openapi.apis.CoreV1Api; + +import org.springframework.cloud.kubernetes.client.config.reload.KubernetesClientEventBasedSecretsChangeDetector; +import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider; +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties; +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy; +import org.springframework.core.env.ConfigurableEnvironment; + +/** + * @author wind57 + */ +public class VisibleKubernetesClientEventBasedSecretsChangeDetector + extends KubernetesClientEventBasedSecretsChangeDetector { + + public VisibleKubernetesClientEventBasedSecretsChangeDetector(CoreV1Api coreV1Api, + ConfigurableEnvironment environment, ConfigReloadProperties properties, + ConfigurationUpdateStrategy strategy, KubernetesClientSecretsPropertySourceLocator propertySourceLocator, + KubernetesNamespaceProvider kubernetesNamespaceProvider) { + super(coreV1Api, environment, properties, strategy, propertySourceLocator, kubernetesNamespaceProvider); + } + + @Override + public void onEvent(KubernetesObject kubernetesObject) { + super.onEvent(kubernetesObject); + } + +} diff --git a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/EventReloadConfigMapTest.java b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/EventReloadConfigMapTest.java new file mode 100644 index 0000000000..a0d458f710 --- /dev/null +++ b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/EventReloadConfigMapTest.java @@ -0,0 +1,255 @@ +/* + * Copyright 2013-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.client.config.reload_it; + +import java.time.Duration; +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 io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.Configuration; +import io.kubernetes.client.openapi.JSON; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1ConfigMap; +import io.kubernetes.client.openapi.models.V1ConfigMapBuilder; +import io.kubernetes.client.openapi.models.V1ConfigMapList; +import io.kubernetes.client.util.ClientBuilder; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.cloud.kubernetes.client.KubernetesClientUtils; +import org.springframework.cloud.kubernetes.client.config.KubernetesClientConfigMapPropertySourceLocator; +import org.springframework.cloud.kubernetes.client.config.VisibleKubernetesClientEventBasedConfigMapChangeDetector; +import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider; +import org.springframework.cloud.kubernetes.commons.config.ConfigMapConfigProperties; +import org.springframework.cloud.kubernetes.commons.config.RetryProperties; +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties; +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.core.env.AbstractEnvironment; +import org.springframework.core.env.PropertySource; +import org.springframework.mock.env.MockEnvironment; + +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; + +/** + * @author wind57 + */ +@SpringBootTest( + properties = { "spring.main.allow-bean-definition-overriding=true", + "logging.level.org.springframework.cloud.kubernetes.commons.config=debug" }, + classes = { EventReloadConfigMapTest.TestConfig.class }) +@ExtendWith(OutputCaptureExtension.class) +class EventReloadConfigMapTest { + + private static final boolean FAIL_FAST = false; + + private static WireMockServer wireMockServer; + + private static final String CONFIG_MAP_NAME = "mine"; + + private static final String NAMESPACE = "spring-k8s"; + + private static final boolean[] strategyCalled = new boolean[] { false }; + + private static CoreV1Api coreV1Api; + + private static final MockedStatic MOCK_STATIC = Mockito + .mockStatic(KubernetesClientUtils.class); + + @Autowired + private VisibleKubernetesClientEventBasedConfigMapChangeDetector kubernetesClientEventBasedConfigMapChangeDetector; + + @BeforeAll + static void setup() { + wireMockServer = new WireMockServer(options().dynamicPort()); + + wireMockServer.start(); + WireMock.configureFor("localhost", wireMockServer.port()); + + ApiClient client = new ClientBuilder().setBasePath("http://localhost:" + wireMockServer.port()).build(); + client.setDebugging(true); + MOCK_STATIC.when(KubernetesClientUtils::createApiClientForInformerClient).thenReturn(client); + MOCK_STATIC + .when(() -> KubernetesClientUtils.getApplicationNamespace(Mockito.anyString(), Mockito.anyString(), + Mockito.any())) + .thenReturn(NAMESPACE); + Configuration.setDefaultApiClient(client); + coreV1Api = new CoreV1Api(); + + String path = "/api/v1/namespaces/spring-k8s/configmaps"; + V1ConfigMap configMapOne = configMap(CONFIG_MAP_NAME, Map.of()); + V1ConfigMapList listOne = new V1ConfigMapList().addItemsItem(configMapOne); + + // needed so that our environment is populated with 'something' + // this call is done in the method that returns the AbstractEnvironment + stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(listOne))) + .inScenario("mine-test") + .willSetStateTo("go-to-fail")); + + // first call will fail + stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error")) + .inScenario("mine-test") + .whenScenarioStateIs("go-to-fail") + .willSetStateTo("go-to-ok")); + + // second call passes (change data so that reload is triggered) + configMapOne = configMap(CONFIG_MAP_NAME, Map.of("a", "b")); + listOne = new V1ConfigMapList().addItemsItem(configMapOne); + stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(listOne))) + .inScenario("mine-test") + .whenScenarioStateIs("go-to-ok") + .willSetStateTo("done")); + } + + @AfterAll + static void after() { + MOCK_STATIC.close(); + wireMockServer.stop(); + } + + /** + *
+	 * 	- 'configmap.mine.spring-k8s' already exists in the environment
+	 * 	-  we simulate that another configmap is created, so a request goes to k8s to find any potential
+	 * 	   differences. This request is mocked to fail.
+	 * 	- then our configmap is changed and the request passes
+	 * 
+ */ + @Test + void test(CapturedOutput output) { + V1ConfigMap configMapNotMine = configMap("not" + CONFIG_MAP_NAME, Map.of()); + kubernetesClientEventBasedConfigMapChangeDetector.onEvent(configMapNotMine); + + // we fail while reading 'configMapOne' + Awaitility.await().atMost(Duration.ofSeconds(10)).pollInterval(Duration.ofSeconds(1)).until(() -> { + boolean one = output.getOut().contains("failure in reading named sources"); + boolean two = output.getOut() + .contains("there was an error while reading config maps/secrets, no reload will happen"); + boolean three = output.getOut() + .contains("reloadable condition was not satisfied, reload will not be triggered"); + boolean updateStrategyNotCalled = !strategyCalled[0]; + return one && two && three && updateStrategyNotCalled; + }); + + // trigger the call again + V1ConfigMap configMapMine = configMap(CONFIG_MAP_NAME, Map.of()); + kubernetesClientEventBasedConfigMapChangeDetector.onEvent(configMapMine); + Awaitility.await() + .atMost(Duration.ofSeconds(10)) + .pollInterval(Duration.ofSeconds(1)) + .until(() -> strategyCalled[0]); + } + + private static V1ConfigMap configMap(String name, Map data) { + return new V1ConfigMapBuilder().withNewMetadata().withName(name).endMetadata().withData(data).build(); + } + + @TestConfiguration + static class TestConfig { + + @Bean + @Primary + VisibleKubernetesClientEventBasedConfigMapChangeDetector kubernetesClientEventBasedConfigMapChangeDetector( + AbstractEnvironment environment, ConfigReloadProperties configReloadProperties, + ConfigurationUpdateStrategy configurationUpdateStrategy, + KubernetesClientConfigMapPropertySourceLocator kubernetesClientConfigMapPropertySourceLocator, + KubernetesNamespaceProvider namespaceProvider) { + return new VisibleKubernetesClientEventBasedConfigMapChangeDetector(coreV1Api, environment, + configReloadProperties, configurationUpdateStrategy, kubernetesClientConfigMapPropertySourceLocator, + namespaceProvider); + } + + @Bean + @Primary + AbstractEnvironment environment() { + MockEnvironment mockEnvironment = new MockEnvironment(); + mockEnvironment.setProperty("spring.cloud.kubernetes.client.namespace", NAMESPACE); + + // simulate that environment already has a + // KubernetesClientConfigMapPropertySource, + // otherwise we can't properly test reload functionality + ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(), + List.of(), Map.of(), true, CONFIG_MAP_NAME, NAMESPACE, false, true, FAIL_FAST, + RetryProperties.DEFAULT); + KubernetesNamespaceProvider namespaceProvider = new KubernetesNamespaceProvider(mockEnvironment); + + PropertySource propertySource = new KubernetesClientConfigMapPropertySourceLocator(coreV1Api, + configMapConfigProperties, namespaceProvider) + .locate(mockEnvironment); + + mockEnvironment.getPropertySources().addFirst(propertySource); + return mockEnvironment; + } + + @Bean + @Primary + ConfigReloadProperties configReloadProperties() { + return new ConfigReloadProperties(true, true, false, ConfigReloadProperties.ReloadStrategy.REFRESH, + ConfigReloadProperties.ReloadDetectionMode.POLLING, Duration.ofMillis(2000), Set.of("non-default"), + false, Duration.ofSeconds(2)); + } + + @Bean + @Primary + ConfigMapConfigProperties configMapConfigProperties() { + return new ConfigMapConfigProperties(true, List.of(), List.of(), Map.of(), true, CONFIG_MAP_NAME, NAMESPACE, + false, true, FAIL_FAST, RetryProperties.DEFAULT); + } + + @Bean + @Primary + KubernetesNamespaceProvider namespaceProvider(AbstractEnvironment environment) { + return new KubernetesNamespaceProvider(environment); + } + + @Bean + @Primary + ConfigurationUpdateStrategy configurationUpdateStrategy() { + return new ConfigurationUpdateStrategy("to-console", () -> { + strategyCalled[0] = true; + }); + } + + @Bean + @Primary + KubernetesClientConfigMapPropertySourceLocator kubernetesClientConfigMapPropertySourceLocator( + ConfigMapConfigProperties configMapConfigProperties, KubernetesNamespaceProvider namespaceProvider) { + return new KubernetesClientConfigMapPropertySourceLocator(coreV1Api, configMapConfigProperties, + namespaceProvider); + } + + } + +} diff --git a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/EventReloadSecretTest.java b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/EventReloadSecretTest.java new file mode 100644 index 0000000000..cc4af32b82 --- /dev/null +++ b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/EventReloadSecretTest.java @@ -0,0 +1,260 @@ +/* + * Copyright 2013-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.client.config.reload_it; + +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.Configuration; +import io.kubernetes.client.openapi.JSON; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1Secret; +import io.kubernetes.client.openapi.models.V1SecretBuilder; +import io.kubernetes.client.openapi.models.V1SecretList; +import io.kubernetes.client.util.ClientBuilder; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.cloud.kubernetes.client.KubernetesClientUtils; +import org.springframework.cloud.kubernetes.client.config.KubernetesClientSecretsPropertySourceLocator; +import org.springframework.cloud.kubernetes.client.config.VisibleKubernetesClientEventBasedSecretsChangeDetector; +import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider; +import org.springframework.cloud.kubernetes.commons.config.RetryProperties; +import org.springframework.cloud.kubernetes.commons.config.SecretsConfigProperties; +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties; +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.core.env.AbstractEnvironment; +import org.springframework.core.env.PropertySource; +import org.springframework.mock.env.MockEnvironment; + +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; + +/** + * @author wind57 + */ +@SpringBootTest( + properties = { "spring.main.allow-bean-definition-overriding=true", + "logging.level.org.springframework.cloud.kubernetes.commons.config=debug" }, + classes = { EventReloadSecretTest.TestConfig.class }) +@ExtendWith(OutputCaptureExtension.class) +class EventReloadSecretTest { + + private static final boolean FAIL_FAST = false; + + private static WireMockServer wireMockServer; + + private static final String SECRET_NAME = "mine"; + + private static final String NAMESPACE = "spring-k8s"; + + private static final boolean[] strategyCalled = new boolean[] { false }; + + private static CoreV1Api coreV1Api; + + private static final MockedStatic MOCK_STATIC = Mockito + .mockStatic(KubernetesClientUtils.class); + + @Autowired + private VisibleKubernetesClientEventBasedSecretsChangeDetector kubernetesClientEventBasedSecretsChangeDetector; + + @BeforeAll + static void setup() { + wireMockServer = new WireMockServer(options().dynamicPort()); + + wireMockServer.start(); + WireMock.configureFor("localhost", wireMockServer.port()); + + ApiClient client = new ClientBuilder().setBasePath("http://localhost:" + wireMockServer.port()).build(); + client.setDebugging(true); + MOCK_STATIC.when(KubernetesClientUtils::createApiClientForInformerClient).thenReturn(client); + MOCK_STATIC + .when(() -> KubernetesClientUtils.getApplicationNamespace(Mockito.anyString(), Mockito.anyString(), + Mockito.any())) + .thenReturn(NAMESPACE); + Configuration.setDefaultApiClient(client); + coreV1Api = new CoreV1Api(); + + String path = "/api/v1/namespaces/spring-k8s/secrets"; + V1Secret secretOne = secret(SECRET_NAME, Map.of()); + V1SecretList listOne = new V1SecretList().addItemsItem(secretOne); + + // needed so that our environment is populated with 'something' + // this call is done in the method that returns the AbstractEnvironment + stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(listOne))) + .inScenario("mine-test") + .willSetStateTo("go-to-fail")); + + // first call will fail + stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error")) + .inScenario("mine-test") + .whenScenarioStateIs("go-to-fail") + .willSetStateTo("go-to-ok")); + + // second call passes (change data so that reload is triggered) + secretOne = secret(SECRET_NAME, Map.of("a", "b")); + listOne = new V1SecretList().addItemsItem(secretOne); + stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(listOne))) + .inScenario("mine-test") + .whenScenarioStateIs("go-to-ok") + .willSetStateTo("done")); + } + + @AfterAll + static void after() { + MOCK_STATIC.close(); + wireMockServer.stop(); + } + + /** + *
+	 * 	- 'secret.mine.spring-k8s' already exists in the environment
+	 * 	-  we simulate that another secret is created, so a request goes to k8s to find any potential
+	 * 	   differences. This request is mocked to fail.
+	 * 	   - then our secret is changed and the request passes
+	 * 
+ */ + @Test + void test(CapturedOutput output) { + V1Secret secretNotMine = secret("not" + SECRET_NAME, Map.of()); + kubernetesClientEventBasedSecretsChangeDetector.onEvent(secretNotMine); + + // we fail while reading 'configMapOne' + Awaitility.await().atMost(Duration.ofSeconds(10)).pollInterval(Duration.ofSeconds(1)).until(() -> { + boolean one = output.getOut().contains("failure in reading named sources"); + boolean two = output.getOut() + .contains("there was an error while reading config maps/secrets, no reload will happen"); + boolean three = output.getOut() + .contains("reloadable condition was not satisfied, reload will not be triggered"); + boolean updateStrategyNotCalled = !strategyCalled[0]; + return one && two && three && updateStrategyNotCalled; + }); + + // trigger the call again + V1Secret secretMine = secret(SECRET_NAME, Map.of()); + kubernetesClientEventBasedSecretsChangeDetector.onEvent(secretMine); + Awaitility.await() + .atMost(Duration.ofSeconds(10)) + .pollInterval(Duration.ofSeconds(1)) + .until(() -> strategyCalled[0]); + } + + private static V1Secret secret(String name, Map data) { + Map encoded = data.entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> Base64.getEncoder().encode(e.getValue().getBytes()))); + + return new V1SecretBuilder().withNewMetadata().withName(name).endMetadata().withData(encoded).build(); + } + + @TestConfiguration + static class TestConfig { + + @Bean + @Primary + VisibleKubernetesClientEventBasedSecretsChangeDetector kubernetesClientEventBasedSecretsChangeDetector( + AbstractEnvironment environment, ConfigReloadProperties configReloadProperties, + ConfigurationUpdateStrategy configurationUpdateStrategy, + KubernetesClientSecretsPropertySourceLocator kubernetesClientSecretsPropertySourceLocator, + KubernetesNamespaceProvider namespaceProvider) { + return new VisibleKubernetesClientEventBasedSecretsChangeDetector(coreV1Api, environment, + configReloadProperties, configurationUpdateStrategy, kubernetesClientSecretsPropertySourceLocator, + namespaceProvider); + } + + @Bean + @Primary + AbstractEnvironment environment() { + MockEnvironment mockEnvironment = new MockEnvironment(); + mockEnvironment.setProperty("spring.cloud.kubernetes.client.namespace", NAMESPACE); + + // simulate that environment already has a + // KubernetesClientConfigMapPropertySource, + // otherwise we can't properly test reload functionality + SecretsConfigProperties secretsConfigProperties = new SecretsConfigProperties(true, Map.of(), List.of(), + List.of(), true, SECRET_NAME, NAMESPACE, false, true, FAIL_FAST, RetryProperties.DEFAULT); + KubernetesNamespaceProvider namespaceProvider = new KubernetesNamespaceProvider(mockEnvironment); + + PropertySource propertySource = new KubernetesClientSecretsPropertySourceLocator(coreV1Api, + namespaceProvider, secretsConfigProperties) + .locate(mockEnvironment); + + mockEnvironment.getPropertySources().addFirst(propertySource); + return mockEnvironment; + } + + @Bean + @Primary + ConfigReloadProperties configReloadProperties() { + return new ConfigReloadProperties(true, true, false, ConfigReloadProperties.ReloadStrategy.REFRESH, + ConfigReloadProperties.ReloadDetectionMode.POLLING, Duration.ofMillis(2000), Set.of("non-default"), + false, Duration.ofSeconds(2)); + } + + @Bean + @Primary + SecretsConfigProperties secretsConfigProperties() { + return new SecretsConfigProperties(true, Map.of(), List.of(), List.of(), true, SECRET_NAME, NAMESPACE, + false, true, FAIL_FAST, RetryProperties.DEFAULT); + } + + @Bean + @Primary + KubernetesNamespaceProvider namespaceProvider(AbstractEnvironment environment) { + return new KubernetesNamespaceProvider(environment); + } + + @Bean + @Primary + ConfigurationUpdateStrategy configurationUpdateStrategy() { + return new ConfigurationUpdateStrategy("to-console", () -> { + strategyCalled[0] = true; + }); + } + + @Bean + @Primary + KubernetesClientSecretsPropertySourceLocator kubernetesClientSecretsPropertySourceLocator( + SecretsConfigProperties secretsConfigProperties, KubernetesNamespaceProvider namespaceProvider) { + return new KubernetesClientSecretsPropertySourceLocator(coreV1Api, namespaceProvider, + secretsConfigProperties); + } + + } + +} diff --git a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/PollingReloadConfigMapTest.java b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/PollingReloadConfigMapTest.java new file mode 100644 index 0000000000..0e55494d6e --- /dev/null +++ b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/PollingReloadConfigMapTest.java @@ -0,0 +1,234 @@ +/* + * Copyright 2013-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.client.config.reload_it; + +import java.time.Duration; +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 io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.Configuration; +import io.kubernetes.client.openapi.JSON; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1ConfigMap; +import io.kubernetes.client.openapi.models.V1ConfigMapBuilder; +import io.kubernetes.client.openapi.models.V1ConfigMapList; +import io.kubernetes.client.util.ClientBuilder; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.cloud.kubernetes.client.config.KubernetesClientConfigMapPropertySource; +import org.springframework.cloud.kubernetes.client.config.KubernetesClientConfigMapPropertySourceLocator; +import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider; +import org.springframework.cloud.kubernetes.commons.config.ConfigMapConfigProperties; +import org.springframework.cloud.kubernetes.commons.config.RetryProperties; +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties; +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy; +import org.springframework.cloud.kubernetes.commons.config.reload.PollingConfigMapChangeDetector; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.core.env.AbstractEnvironment; +import org.springframework.core.env.PropertySource; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +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; + +/** + * @author wind57 + */ +@SpringBootTest( + properties = { "spring.main.allow-bean-definition-overriding=true", + "logging.level.org.springframework.cloud.kubernetes.commons.config=debug" }, + classes = { PollingReloadConfigMapTest.TestConfig.class }) +@ExtendWith(OutputCaptureExtension.class) +class PollingReloadConfigMapTest { + + private static WireMockServer wireMockServer; + + private static final boolean FAIL_FAST = false; + + private static final String CONFIG_MAP_NAME = "mine"; + + private static final String NAMESPACE = "spring-k8s"; + + private static final boolean[] strategyCalled = new boolean[] { false }; + + private static CoreV1Api coreV1Api; + + @BeforeAll + static void setup() { + wireMockServer = new WireMockServer(options().dynamicPort()); + + wireMockServer.start(); + WireMock.configureFor("localhost", wireMockServer.port()); + + ApiClient client = new ClientBuilder().setBasePath("http://localhost:" + wireMockServer.port()).build(); + client.setDebugging(true); + Configuration.setDefaultApiClient(client); + coreV1Api = new CoreV1Api(); + + String path = "/api/v1/namespaces/spring-k8s/configmaps"; + V1ConfigMap configMapOne = configMap(CONFIG_MAP_NAME, Map.of()); + V1ConfigMapList listOne = new V1ConfigMapList().addItemsItem(configMapOne); + + // needed so that our environment is populated with 'something' + // this call is done in the method that returns the AbstractEnvironment + stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(listOne))) + .inScenario("my-test") + .willSetStateTo("go-to-fail")); + + // first reload call fails + stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error")) + .inScenario("my-test") + .whenScenarioStateIs("go-to-fail") + .willSetStateTo("go-to-ok")); + + // second reload call passes + V1ConfigMap configMapTwo = configMap(CONFIG_MAP_NAME, Map.of("a", "b")); + V1ConfigMapList listTwo = new V1ConfigMapList().addItemsItem(configMapTwo); + stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(listTwo))) + .inScenario("my-test") + .whenScenarioStateIs("go-to-ok")); + + } + + @AfterAll + static void after() { + wireMockServer.stop(); + } + + /** + *
+	 *     - we have a PropertySource in the environment
+	 *     - first polling cycle tries to read the sources from k8s and fails
+	 *     - second polling cycle reads sources from k8s and finds a change
+	 * 
+ */ + @Test + void test(CapturedOutput output) { + // we fail while reading 'configMapOne' + Awaitility.await().atMost(Duration.ofSeconds(10)).pollInterval(Duration.ofSeconds(1)).until(() -> { + boolean one = output.getOut().contains("failure in reading named sources"); + boolean two = output.getOut() + .contains("there was an error while reading config maps/secrets, no reload will happen"); + boolean three = output.getOut() + .contains("reloadable condition was not satisfied, reload will not be triggered"); + boolean updateStrategyNotCalled = !strategyCalled[0]; + return one && two && three && updateStrategyNotCalled; + }); + + // it passes while reading 'configMapTwo' + Awaitility.await() + .atMost(Duration.ofSeconds(10)) + .pollInterval(Duration.ofSeconds(1)) + .until(() -> strategyCalled[0]); + } + + private static V1ConfigMap configMap(String name, Map data) { + return new V1ConfigMapBuilder().withNewMetadata().withName(name).endMetadata().withData(data).build(); + } + + @TestConfiguration + static class TestConfig { + + @Bean + @Primary + PollingConfigMapChangeDetector pollingConfigMapChangeDetector(AbstractEnvironment environment, + ConfigReloadProperties configReloadProperties, ConfigurationUpdateStrategy configurationUpdateStrategy, + KubernetesClientConfigMapPropertySourceLocator kubernetesClientConfigMapPropertySourceLocator) { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.initialize(); + return new PollingConfigMapChangeDetector(environment, configReloadProperties, configurationUpdateStrategy, + KubernetesClientConfigMapPropertySource.class, kubernetesClientConfigMapPropertySourceLocator, + scheduler); + } + + @Bean + @Primary + AbstractEnvironment environment() { + MockEnvironment mockEnvironment = new MockEnvironment(); + mockEnvironment.setProperty("spring.cloud.kubernetes.client.namespace", NAMESPACE); + + // simulate that environment already has a + // KubernetesClientConfigMapPropertySource, + // otherwise we can't properly test reload functionality + ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(), + List.of(), Map.of(), true, CONFIG_MAP_NAME, NAMESPACE, false, true, true, RetryProperties.DEFAULT); + KubernetesNamespaceProvider namespaceProvider = new KubernetesNamespaceProvider(mockEnvironment); + + PropertySource propertySource = new KubernetesClientConfigMapPropertySourceLocator(coreV1Api, + configMapConfigProperties, namespaceProvider) + .locate(mockEnvironment); + + mockEnvironment.getPropertySources().addFirst(propertySource); + return mockEnvironment; + } + + @Bean + @Primary + ConfigReloadProperties configReloadProperties() { + return new ConfigReloadProperties(true, true, false, ConfigReloadProperties.ReloadStrategy.REFRESH, + ConfigReloadProperties.ReloadDetectionMode.POLLING, Duration.ofMillis(2000), Set.of("non-default"), + false, Duration.ofSeconds(2)); + } + + @Bean + @Primary + ConfigMapConfigProperties configMapConfigProperties() { + return new ConfigMapConfigProperties(true, List.of(), List.of(), Map.of(), true, CONFIG_MAP_NAME, NAMESPACE, + false, true, FAIL_FAST, RetryProperties.DEFAULT); + } + + @Bean + @Primary + KubernetesNamespaceProvider namespaceProvider(AbstractEnvironment environment) { + return new KubernetesNamespaceProvider(environment); + } + + @Bean + @Primary + ConfigurationUpdateStrategy configurationUpdateStrategy() { + return new ConfigurationUpdateStrategy("to-console", () -> { + strategyCalled[0] = true; + }); + } + + @Bean + @Primary + KubernetesClientConfigMapPropertySourceLocator kubernetesClientConfigMapPropertySourceLocator( + ConfigMapConfigProperties configMapConfigProperties, KubernetesNamespaceProvider namespaceProvider) { + return new KubernetesClientConfigMapPropertySourceLocator(coreV1Api, configMapConfigProperties, + namespaceProvider); + } + + } + +} diff --git a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/PollingReloadSecretTest.java b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/PollingReloadSecretTest.java new file mode 100644 index 0000000000..4173ac8da2 --- /dev/null +++ b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/PollingReloadSecretTest.java @@ -0,0 +1,240 @@ +/* + * Copyright 2013-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.client.config.reload_it; + +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.Configuration; +import io.kubernetes.client.openapi.JSON; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1Secret; +import io.kubernetes.client.openapi.models.V1SecretBuilder; +import io.kubernetes.client.openapi.models.V1SecretList; +import io.kubernetes.client.util.ClientBuilder; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.cloud.kubernetes.client.config.KubernetesClientSecretsPropertySource; +import org.springframework.cloud.kubernetes.client.config.KubernetesClientSecretsPropertySourceLocator; +import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider; +import org.springframework.cloud.kubernetes.commons.config.RetryProperties; +import org.springframework.cloud.kubernetes.commons.config.SecretsConfigProperties; +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties; +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy; +import org.springframework.cloud.kubernetes.commons.config.reload.PollingSecretsChangeDetector; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.core.env.AbstractEnvironment; +import org.springframework.core.env.PropertySource; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +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; + +/** + * @author wind57 + */ +@SpringBootTest( + properties = { "spring.main.allow-bean-definition-overriding=true", + "logging.level.org.springframework.cloud.kubernetes.commons.config=debug" }, + classes = { PollingReloadSecretTest.TestConfig.class }) +@ExtendWith(OutputCaptureExtension.class) +class PollingReloadSecretTest { + + private static WireMockServer wireMockServer; + + private static final boolean FAIL_FAST = false; + + private static final String SECRET_NAME = "mine"; + + private static final String NAMESPACE = "spring-k8s"; + + private static final boolean[] strategyCalled = new boolean[] { false }; + + private static CoreV1Api coreV1Api; + + @BeforeAll + static void setup() { + wireMockServer = new WireMockServer(options().dynamicPort()); + + wireMockServer.start(); + WireMock.configureFor("localhost", wireMockServer.port()); + + ApiClient client = new ClientBuilder().setBasePath("http://localhost:" + wireMockServer.port()).build(); + client.setDebugging(true); + Configuration.setDefaultApiClient(client); + coreV1Api = new CoreV1Api(); + + String path = "/api/v1/namespaces/spring-k8s/secrets"; + V1Secret secretOne = secret(SECRET_NAME, Map.of()); + V1SecretList listOne = new V1SecretList().addItemsItem(secretOne); + + // needed so that our environment is populated with 'something' + // this call is done in the method that returns the AbstractEnvironment + stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(listOne))) + .inScenario("my-test") + .willSetStateTo("go-to-fail")); + + // first reload call fails + stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error")) + .inScenario("my-test") + .whenScenarioStateIs("go-to-fail") + .willSetStateTo("go-to-ok")); + + V1Secret secretTwo = secret(SECRET_NAME, Map.of("a", "b")); + V1SecretList listTwo = new V1SecretList().addItemsItem(secretTwo); + stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(listTwo))) + .inScenario("my-test") + .whenScenarioStateIs("go-to-ok")); + + } + + @AfterAll + static void after() { + wireMockServer.stop(); + } + + /** + *
+	 *     - we have a PropertySource in the environment
+	 *     - first polling cycle tries to read the sources from k8s and fails
+	 *     - second polling cycle reads sources from k8s and finds a change
+	 * 
+ */ + @Test + void test(CapturedOutput output) { + // we fail while reading 'secretOne' + Awaitility.await().atMost(Duration.ofSeconds(10)).pollInterval(Duration.ofSeconds(1)).until(() -> { + boolean one = output.getOut().contains("failure in reading named sources"); + boolean two = output.getOut() + .contains("there was an error while reading config maps/secrets, no reload will happen"); + boolean three = output.getOut() + .contains("reloadable condition was not satisfied, reload will not be triggered"); + boolean updateStrategyNotCalled = !strategyCalled[0]; + return one && two && three && updateStrategyNotCalled; + }); + + // it passes while reading 'secretTwo' + Awaitility.await() + .atMost(Duration.ofSeconds(10)) + .pollInterval(Duration.ofSeconds(1)) + .until(() -> strategyCalled[0]); + } + + private static V1Secret secret(String name, Map data) { + + Map encoded = data.entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> Base64.getEncoder().encode(e.getValue().getBytes()))); + + return new V1SecretBuilder().withNewMetadata().withName(name).endMetadata().withData(encoded).build(); + } + + @TestConfiguration + static class TestConfig { + + @Bean + @Primary + PollingSecretsChangeDetector pollingSecretsChangeDetector(AbstractEnvironment environment, + ConfigReloadProperties configReloadProperties, ConfigurationUpdateStrategy configurationUpdateStrategy, + KubernetesClientSecretsPropertySourceLocator kubernetesClientSecretsPropertySourceLocator) { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.initialize(); + return new PollingSecretsChangeDetector(environment, configReloadProperties, configurationUpdateStrategy, + KubernetesClientSecretsPropertySource.class, kubernetesClientSecretsPropertySourceLocator, + scheduler); + } + + @Bean + @Primary + AbstractEnvironment environment() { + MockEnvironment mockEnvironment = new MockEnvironment(); + mockEnvironment.setProperty("spring.cloud.kubernetes.client.namespace", NAMESPACE); + + // simulate that environment already has a + // KubernetesClientSecretPropertySource, + // otherwise we can't properly test reload functionality + SecretsConfigProperties secretsConfigProperties = new SecretsConfigProperties(true, Map.of(), List.of(), + List.of(), true, SECRET_NAME, NAMESPACE, false, true, false, RetryProperties.DEFAULT); + KubernetesNamespaceProvider namespaceProvider = new KubernetesNamespaceProvider(mockEnvironment); + + PropertySource propertySource = new KubernetesClientSecretsPropertySourceLocator(coreV1Api, + namespaceProvider, secretsConfigProperties) + .locate(mockEnvironment); + + mockEnvironment.getPropertySources().addFirst(propertySource); + return mockEnvironment; + } + + @Bean + @Primary + ConfigReloadProperties configReloadProperties() { + return new ConfigReloadProperties(true, false, true, ConfigReloadProperties.ReloadStrategy.REFRESH, + ConfigReloadProperties.ReloadDetectionMode.POLLING, Duration.ofMillis(2000), Set.of("non-default"), + false, Duration.ofSeconds(2)); + } + + @Bean + @Primary + SecretsConfigProperties secretsConfigProperties() { + return new SecretsConfigProperties(true, Map.of(), List.of(), List.of(), true, SECRET_NAME, NAMESPACE, + false, true, FAIL_FAST, RetryProperties.DEFAULT); + } + + @Bean + @Primary + KubernetesNamespaceProvider namespaceProvider(AbstractEnvironment environment) { + return new KubernetesNamespaceProvider(environment); + } + + @Bean + @Primary + ConfigurationUpdateStrategy configurationUpdateStrategy() { + return new ConfigurationUpdateStrategy("to-console", () -> { + strategyCalled[0] = true; + }); + } + + @Bean + @Primary + KubernetesClientSecretsPropertySourceLocator kubernetesClientSecretsPropertySourceLocator( + SecretsConfigProperties secretsConfigProperties, KubernetesNamespaceProvider namespaceProvider) { + return new KubernetesClientSecretsPropertySourceLocator(coreV1Api, namespaceProvider, + secretsConfigProperties); + } + + } + +} diff --git a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/Constants.java b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/Constants.java index e8be954fb8..072770f1dd 100644 --- a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/Constants.java +++ b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/Constants.java @@ -63,6 +63,12 @@ public final class Constants { */ public static final String RELOAD_MODE = "spring.cloud.kubernetes.reload.mode"; + /** + * property set to true when there was an error reading config maps or secrets, when + * generating a property source. + */ + public static final String ERROR_PROPERTY = "spring.cloud.k8s.error.reading.property.source"; + private Constants() { } diff --git a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/LabeledSourceData.java b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/LabeledSourceData.java index 7c3068995c..61178346d9 100644 --- a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/LabeledSourceData.java +++ b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/LabeledSourceData.java @@ -21,7 +21,11 @@ import java.util.Set; import java.util.stream.Collectors; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + import static org.springframework.cloud.kubernetes.commons.config.ConfigUtils.onException; +import static org.springframework.cloud.kubernetes.commons.config.Constants.ERROR_PROPERTY; import static org.springframework.cloud.kubernetes.commons.config.Constants.PROPERTY_SOURCE_NAME_SEPARATOR; /** @@ -32,6 +36,8 @@ */ public abstract class LabeledSourceData { + private static final Log LOG = LogFactory.getLog(LabeledSourceData.class); + public final SourceData compute(Map labels, ConfigUtils.Prefix prefix, String target, boolean profileSources, boolean failFast, String namespace, String[] activeProfiles) { @@ -73,7 +79,9 @@ public final SourceData compute(Map labels, ConfigUtils.Prefix p } } catch (Exception e) { + LOG.warn("failure in reading labeled sources"); onException(failFast, e); + data = new MultipleSourcesContainer(data.names(), Map.of(ERROR_PROPERTY, "true")); } String names = data.names().stream().sorted().collect(Collectors.joining(PROPERTY_SOURCE_NAME_SEPARATOR)); diff --git a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/NamedSourceData.java b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/NamedSourceData.java index c198e43316..acd39b64ba 100644 --- a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/NamedSourceData.java +++ b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/NamedSourceData.java @@ -17,12 +17,14 @@ package org.springframework.cloud.kubernetes.commons.config; import java.util.LinkedHashSet; +import java.util.Map; import java.util.stream.Collectors; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import static org.springframework.cloud.kubernetes.commons.config.ConfigUtils.onException; +import static org.springframework.cloud.kubernetes.commons.config.Constants.ERROR_PROPERTY; import static org.springframework.cloud.kubernetes.commons.config.Constants.PROPERTY_SOURCE_NAME_SEPARATOR; /** @@ -69,7 +71,9 @@ public final SourceData compute(String sourceName, ConfigUtils.Prefix prefix, St } catch (Exception e) { + LOG.warn("failure in reading named sources"); onException(failFast, e); + data = new MultipleSourcesContainer(data.names(), Map.of(ERROR_PROPERTY, "true")); } String names = data.names().stream().sorted().collect(Collectors.joining(PROPERTY_SOURCE_NAME_SEPARATOR)); diff --git a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/reload/ConfigReloadUtil.java b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/reload/ConfigReloadUtil.java index 4279694418..02de15f2c1 100644 --- a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/reload/ConfigReloadUtil.java +++ b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/reload/ConfigReloadUtil.java @@ -33,6 +33,8 @@ import org.springframework.core.env.PropertySource; import org.springframework.core.log.LogAccessor; +import static org.springframework.cloud.kubernetes.commons.config.Constants.ERROR_PROPERTY; + /** * @author wind57 */ @@ -71,11 +73,11 @@ public static boolean reload(PropertySourceLocator locator, ConfigurableEnvironm boolean changed = changed(sourceFromK8s, existingSources); if (changed) { - LOG.info("Detected change in config maps/secrets"); + LOG.info("Detected change in config maps/secrets, reload will ne triggered"); return true; } else { - LOG.debug("No change detected in config maps/secrets, reload will not happen"); + LOG.debug("reloadable condition was not satisfied, reload will not be triggered"); } return false; @@ -162,6 +164,12 @@ else if (propertySource instanceof CompositePropertySource source) { } static boolean changed(List k8sSources, List appSources) { + + if (k8sSources.stream().anyMatch(source -> "true".equals(source.getProperty(ERROR_PROPERTY)))) { + LOG.info(() -> "there was an error while reading config maps/secrets, no reload will happen"); + return false; + } + if (k8sSources.size() != appSources.size()) { if (LOG.isDebugEnabled()) { LOG.debug("k8s property sources size: " + k8sSources.size()); diff --git a/spring-cloud-kubernetes-commons/src/test/java/org/springframework/cloud/kubernetes/commons/config/reload/ConfigReloadUtilTests.java b/spring-cloud-kubernetes-commons/src/test/java/org/springframework/cloud/kubernetes/commons/config/reload/ConfigReloadUtilTests.java index 280d95f9d5..df1f54b2b2 100644 --- a/spring-cloud-kubernetes-commons/src/test/java/org/springframework/cloud/kubernetes/commons/config/reload/ConfigReloadUtilTests.java +++ b/spring-cloud-kubernetes-commons/src/test/java/org/springframework/cloud/kubernetes/commons/config/reload/ConfigReloadUtilTests.java @@ -26,6 +26,7 @@ import org.junit.jupiter.api.Test; import org.springframework.cloud.bootstrap.config.BootstrapPropertySource; +import org.springframework.cloud.kubernetes.commons.config.Constants; import org.springframework.cloud.kubernetes.commons.config.MountConfigMapPropertySource; import org.springframework.core.env.CompositePropertySource; import org.springframework.core.env.EnumerablePropertySource; @@ -150,6 +151,16 @@ public Object getProperty(String name) { Assertions.assertEquals("from-inner-two-composite", result.get(3).getProperty("")); } + @Test + void testEmptySourceNameOnError() { + Object value = new Object(); + Map rightMap = Map.of("key", value); + MapPropertySource left = new MapPropertySource("on-error", Map.of(Constants.ERROR_PROPERTY, "true")); + MapPropertySource right = new MapPropertySource("right", rightMap); + boolean changed = ConfigReloadUtil.changed(List.of(left), List.of(right)); + assertThat(changed).isFalse(); + } + private static final class OneComposite extends CompositePropertySource { private OneComposite() { diff --git a/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/Fabric8ConfigMapErrorOnReadingSourceTests.java b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/Fabric8ConfigMapErrorOnReadingSourceTests.java new file mode 100644 index 0000000000..549cb4dd5f --- /dev/null +++ b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/Fabric8ConfigMapErrorOnReadingSourceTests.java @@ -0,0 +1,291 @@ +/* + * Copyright 2013-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.fabric8.config; + +import java.util.List; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.ConfigMapListBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient; +import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider; +import org.springframework.cloud.kubernetes.commons.config.ConfigMapConfigProperties; +import org.springframework.cloud.kubernetes.commons.config.Constants; +import org.springframework.cloud.kubernetes.commons.config.RetryProperties; +import org.springframework.core.env.CompositePropertySource; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.PropertySource; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.cloud.kubernetes.commons.config.ConfigMapConfigProperties.Source; + +/** + * @author wind57 + */ +@EnableKubernetesMockClient +@ExtendWith(OutputCaptureExtension.class) +class Fabric8ConfigMapErrorOnReadingSourceTests { + + private static KubernetesMockServer mockServer; + + private static KubernetesClient mockClient; + + @BeforeAll + static void beforeAll() { + mockClient.getConfiguration().setRequestRetryBackoffLimit(0); + } + + /** + *
+	 *     we try to read all config maps in a namespace and fail,
+	 *     thus generate a well defined name for the source.
+	 * 
+ */ + @Test + void namedSingleConfigMapFails() { + String name = "my-config"; + String namespace = "spring-k8s"; + String path = "/api/v1/namespaces/" + namespace + "/configmaps"; + + mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").once(); + + ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(), List.of(), + Map.of(), true, name, namespace, false, true, false, RetryProperties.DEFAULT); + + Fabric8ConfigMapPropertySourceLocator locator = new Fabric8ConfigMapPropertySourceLocator(mockClient, + configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment())); + + CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment()); + MapPropertySource mapPropertySource = (MapPropertySource) propertySource.getPropertySources() + .stream() + .findAny() + .orElseThrow(); + + assertThat(mapPropertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true"); + + } + + /** + *
+	 *     there are two sources and we try to read them.
+	 *     one fails and one passes.
+	 * 
+ */ + @Test + void namedTwoConfigMapsOneFails() { + String configMapNameOne = "one"; + String configMapNameTwo = "two"; + String namespace = "default"; + String path = "/api/v1/namespaces/default/configmaps"; + + ConfigMap configMapTwo = configMap(configMapNameTwo, Map.of()); + + mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").once(); + mockServer.expect() + .withPath(path) + .andReturn(200, new ConfigMapListBuilder().withItems(configMapTwo).build()) + .once(); + + Source sourceOne = new Source(configMapNameOne, namespace, Map.of(), null, null, null); + Source sourceTwo = new Source(configMapNameTwo, namespace, Map.of(), null, null, null); + + ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(), + List.of(sourceOne, sourceTwo), Map.of(), true, null, namespace, false, true, false, + RetryProperties.DEFAULT); + + Fabric8ConfigMapPropertySourceLocator locator = new Fabric8ConfigMapPropertySourceLocator(mockClient, + configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment())); + + CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment()); + List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList(); + + // two sources are present + assertThat(names).containsExactly("configmap.two.default", "configmap..default"); + assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true"); + + } + + /** + *
+	 *     there are two sources and we try to read them.
+	 *     both fail.
+	 * 
+ */ + @Test + void namedTwoConfigMapsBothFail() { + String configMapNameOne = "one"; + String configMapNameTwo = "two"; + String namespace = "default"; + String path = "/api/v1/namespaces/default/configmaps"; + + mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").once(); + mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").once(); + + Source sourceOne = new Source(configMapNameOne, namespace, Map.of(), null, null, null); + Source sourceTwo = new Source(configMapNameTwo, namespace, Map.of(), null, null, null); + + ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(), + List.of(sourceOne, sourceTwo), Map.of(), true, null, namespace, false, true, false, + RetryProperties.DEFAULT); + + Fabric8ConfigMapPropertySourceLocator locator = new Fabric8ConfigMapPropertySourceLocator(mockClient, + configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment())); + + CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment()); + List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList(); + + assertThat(names).containsExactly("configmap..default"); + assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true"); + + } + + /** + *
+	 *     we try to read all config maps in a namespace and fail,
+	 *     thus generate a well defined name for the source.
+	 * 
+ */ + @Test + void labeledSingleConfigMapFails(CapturedOutput output) { + Map labels = Map.of("a", "b"); + String namespace = "spring-k8s"; + String path = "/api/v1/namespaces/" + namespace + "/configmaps"; + + // one for the 'application' named configmap + // the other for the labeled config map + mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").times(2); + + Source configMapSource = new Source(null, namespace, labels, null, null, null); + + ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(), + List.of(configMapSource), labels, true, null, namespace, false, true, false, RetryProperties.DEFAULT); + + Fabric8ConfigMapPropertySourceLocator locator = new Fabric8ConfigMapPropertySourceLocator(mockClient, + configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment())); + + CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment()); + List sourceNames = propertySource.getPropertySources().stream().map(PropertySource::getName).toList(); + + assertThat(sourceNames).containsExactly("configmap..spring-k8s"); + assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true"); + assertThat(output).contains("failure in reading labeled sources"); + assertThat(output).contains("failure in reading named sources"); + } + + /** + *
+	 *     there are two sources and we try to read them.
+	 *     one fails and one passes.
+	 * 
+ */ + @Test + void labeledTwoConfigMapsOneFails(CapturedOutput output) { + String configMapNameOne = "one"; + String configMapNameTwo = "two"; + + Map configMapOneLabels = Map.of("one", "1"); + Map configMapTwoLabels = Map.of("two", "2"); + + String namespace = "default"; + String path = "/api/v1/namespaces/default/configmaps"; + + ConfigMap configMapOne = configMap(configMapNameOne, configMapOneLabels); + ConfigMap configMapTwo = configMap(configMapNameTwo, configMapTwoLabels); + + // one for 'application' named configmap and one for the first labeled configmap + mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").times(2); + mockServer.expect() + .withPath(path) + .andReturn(200, new ConfigMapListBuilder().withItems(configMapOne, configMapTwo).build()) + .once(); + + Source sourceOne = new Source(null, namespace, configMapOneLabels, null, null, null); + Source sourceTwo = new Source(null, namespace, configMapTwoLabels, null, null, null); + + ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(), + List.of(sourceOne, sourceTwo), Map.of("one", "1", "two", "2"), true, null, namespace, false, true, + false, RetryProperties.DEFAULT); + + Fabric8ConfigMapPropertySourceLocator locator = new Fabric8ConfigMapPropertySourceLocator(mockClient, + configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment())); + + CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment()); + List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList(); + + // two sources are present, one being empty + assertThat(names).containsExactly("configmap.two.default", "configmap..default"); + assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true"); + + assertThat(output).contains("failure in reading labeled sources"); + assertThat(output).contains("failure in reading named sources"); + + } + + /** + *
+	 *     there are two sources and we try to read them.
+	 *     both fail.
+	 * 
+ */ + @Test + void labeledTwoConfigMapsBothFail(CapturedOutput output) { + + Map configMapOneLabels = Map.of("one", "1"); + Map configMapTwoLabels = Map.of("two", "2"); + + String namespace = "default"; + String path = "/api/v1/namespaces/default/configmaps"; + + // one for 'application' named configmap and two for the labeled configmaps + mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").times(3); + + Source sourceOne = new Source(null, namespace, configMapOneLabels, null, null, null); + Source sourceTwo = new Source(null, namespace, configMapTwoLabels, null, null, null); + + ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(), + List.of(sourceOne, sourceTwo), Map.of("one", "1", "two", "2"), true, null, namespace, false, true, + false, RetryProperties.DEFAULT); + + Fabric8ConfigMapPropertySourceLocator locator = new Fabric8ConfigMapPropertySourceLocator(mockClient, + configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment())); + + CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment()); + List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList(); + + assertThat(names).containsExactly("configmap..default"); + assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true"); + + assertThat(output).contains("failure in reading labeled sources"); + assertThat(output).contains("failure in reading named sources"); + + } + + private ConfigMap configMap(String name, Map labels) { + return new ConfigMapBuilder().withNewMetadata().withName(name).withLabels(labels).endMetadata().build(); + } + +} diff --git a/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/Fabric8SecretErrorOnReadingSourceTests.java b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/Fabric8SecretErrorOnReadingSourceTests.java new file mode 100644 index 0000000000..4c203861f3 --- /dev/null +++ b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/Fabric8SecretErrorOnReadingSourceTests.java @@ -0,0 +1,288 @@ +/* + * Copyright 2013-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.fabric8.config; + +import java.util.List; +import java.util.Map; + +import io.fabric8.kubernetes.api.model.Secret; +import io.fabric8.kubernetes.api.model.SecretBuilder; +import io.fabric8.kubernetes.api.model.SecretListBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient; +import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider; +import org.springframework.cloud.kubernetes.commons.config.Constants; +import org.springframework.cloud.kubernetes.commons.config.RetryProperties; +import org.springframework.cloud.kubernetes.commons.config.SecretsConfigProperties; +import org.springframework.core.env.CompositePropertySource; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.PropertySource; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.cloud.kubernetes.commons.config.SecretsConfigProperties.Source; + +/** + * @author wind57 + */ +@EnableKubernetesMockClient +@ExtendWith(OutputCaptureExtension.class) +class Fabric8SecretErrorOnReadingSourceTests { + + private static KubernetesMockServer mockServer; + + private static KubernetesClient mockClient; + + @BeforeAll + static void beforeAll() { + mockClient.getConfiguration().setRequestRetryBackoffLimit(0); + } + + /** + *
+	 *     we try to read all secrets in a namespace and fail,
+	 *     thus generate a well defined name for the source.
+	 * 
+ */ + @Test + void namedSingleSecretFails(CapturedOutput output) { + String name = "my-secret"; + String namespace = "spring-k8s"; + String path = "/api/v1/namespaces/" + namespace + "/secrets"; + + mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").once(); + + SecretsConfigProperties secretsConfigProperties = new SecretsConfigProperties(true, Map.of(), List.of(), + List.of(), true, name, namespace, false, true, false, RetryProperties.DEFAULT); + + Fabric8SecretsPropertySourceLocator locator = new Fabric8SecretsPropertySourceLocator(mockClient, + secretsConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment())); + + CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment()); + MapPropertySource mapPropertySource = (MapPropertySource) propertySource.getPropertySources() + .stream() + .findAny() + .orElseThrow(); + + assertThat(mapPropertySource.getName()).isEqualTo("secret..spring-k8s"); + assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true"); + assertThat(output).contains("failure in reading named sources"); + + } + + /** + *
+	 *     there are two sources and we try to read them.
+	 *     one fails and one passes.
+	 * 
+ */ + @Test + void namedTwoSecretsOneFails() { + String secretNameOne = "one"; + String secretNameTwo = "two"; + String namespace = "default"; + String path = "/api/v1/namespaces/default/secrets"; + + Secret secretTwo = secret(secretNameTwo, Map.of()); + + mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").once(); + mockServer.expect().withPath(path).andReturn(200, new SecretListBuilder().withItems(secretTwo).build()).once(); + + Source sourceOne = new Source(secretNameOne, namespace, Map.of(), null, null, null); + Source sourceTwo = new Source(secretNameTwo, namespace, Map.of(), null, null, null); + + SecretsConfigProperties secretsConfigProperties = new SecretsConfigProperties(true, Map.of(), List.of(), + List.of(sourceOne, sourceTwo), true, null, namespace, false, true, false, RetryProperties.DEFAULT); + + Fabric8SecretsPropertySourceLocator locator = new Fabric8SecretsPropertySourceLocator(mockClient, + secretsConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment())); + + CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment()); + List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList(); + + // two sources are present, one being empty + assertThat(names).containsExactly("secret.two.default", "secret..default"); + assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true"); + + } + + /** + *
+	 *     there are two sources and we try to read them.
+	 *     both fail.
+	 * 
+ */ + @Test + void namedTwoSecretsBothFail() { + String secretNameOne = "one"; + String secretNameTwo = "two"; + String namespace = "default"; + String path = "/api/v1/namespaces/default/secrets"; + + mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").once(); + mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").once(); + + Source sourceOne = new Source(secretNameOne, namespace, Map.of(), null, null, null); + Source sourceTwo = new Source(secretNameTwo, namespace, Map.of(), null, null, null); + + SecretsConfigProperties secretsConfigProperties = new SecretsConfigProperties(true, Map.of(), List.of(), + List.of(sourceOne, sourceTwo), true, null, namespace, false, true, false, RetryProperties.DEFAULT); + + Fabric8SecretsPropertySourceLocator locator = new Fabric8SecretsPropertySourceLocator(mockClient, + secretsConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment())); + + CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment()); + List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList(); + + assertThat(names).containsExactly("secret..default"); + assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true"); + + } + + /** + *
+	 *     we try to read all secrets in a namespace and fail,
+	 *     thus generate a well defined name for the source.
+	 * 
+ */ + @Test + void labeledSingleSecretFails(CapturedOutput output) { + Map labels = Map.of("a", "b"); + String namespace = "spring-k8s"; + String path = "/api/v1/namespaces/" + namespace + "/secrets"; + + // one for the 'application' named secret + // the other for the labeled secret + mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").times(2); + + Source secretSource = new Source(null, namespace, labels, null, null, null); + + SecretsConfigProperties secretsConfigProperties = new SecretsConfigProperties(true, labels, List.of(), + List.of(secretSource), true, null, namespace, false, true, false, RetryProperties.DEFAULT); + + Fabric8SecretsPropertySourceLocator locator = new Fabric8SecretsPropertySourceLocator(mockClient, + secretsConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment())); + + CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment()); + List sourceNames = propertySource.getPropertySources().stream().map(PropertySource::getName).toList(); + + assertThat(sourceNames).containsExactly("secret..spring-k8s"); + assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true"); + assertThat(output).contains("failure in reading labeled sources"); + assertThat(output).contains("failure in reading named sources"); + } + + /** + *
+	 *     there are two sources and we try to read them.
+	 *     one fails and one passes.
+	 * 
+ */ + @Test + void labeledTwoSecretsOneFails(CapturedOutput output) { + String secretNameOne = "one"; + String secretNameTwo = "two"; + + Map secretOneLabels = Map.of("one", "1"); + Map secretTwoLabels = Map.of("two", "2"); + + String namespace = "default"; + String path = "/api/v1/namespaces/default/secrets"; + + Secret secretOne = secret(secretNameOne, secretOneLabels); + Secret secretTwo = secret(secretNameTwo, secretTwoLabels); + + // one for 'application' named secret and one for the first labeled secret + mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").times(2); + mockServer.expect() + .withPath(path) + .andReturn(200, new SecretListBuilder().withItems(secretOne, secretTwo).build()) + .once(); + + Source sourceOne = new Source(null, namespace, secretOneLabels, null, null, null); + Source sourceTwo = new Source(null, namespace, secretTwoLabels, null, null, null); + + SecretsConfigProperties secretsConfigProperties = new SecretsConfigProperties(true, + Map.of("one", "1", "two", "2"), List.of(), List.of(sourceOne, sourceTwo), true, null, namespace, false, + true, false, RetryProperties.DEFAULT); + + Fabric8SecretsPropertySourceLocator locator = new Fabric8SecretsPropertySourceLocator(mockClient, + secretsConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment())); + + CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment()); + List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList(); + + // two sources are present, one being empty + assertThat(names).containsExactly("secret.two.default", "secret..default"); + assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true"); + + assertThat(output).contains("failure in reading labeled sources"); + assertThat(output).contains("failure in reading named sources"); + + } + + /** + *
+	 *     there are two sources and we try to read them.
+	 *     both fail.
+	 * 
+ */ + @Test + void labeledTwoConfigMapsBothFail(CapturedOutput output) { + + Map secretOneLabels = Map.of("one", "1"); + Map secretTwoLabels = Map.of("two", "2"); + + String namespace = "default"; + String path = "/api/v1/namespaces/default/secrets"; + + // one for 'application' named configmap and two for the labeled configmaps + mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").times(3); + + Source sourceOne = new Source(null, namespace, secretOneLabels, null, null, null); + Source sourceTwo = new Source(null, namespace, secretTwoLabels, null, null, null); + + SecretsConfigProperties secretsConfigProperties = new SecretsConfigProperties(true, + Map.of("one", "1", "two", "2"), List.of(), List.of(sourceOne, sourceTwo), true, null, namespace, false, + true, false, RetryProperties.DEFAULT); + + Fabric8SecretsPropertySourceLocator locator = new Fabric8SecretsPropertySourceLocator(mockClient, + secretsConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment())); + + CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment()); + List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList(); + + assertThat(names).containsExactly("secret..default"); + assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true"); + + assertThat(output).contains("failure in reading labeled sources"); + assertThat(output).contains("failure in reading named sources"); + + } + + private Secret secret(String name, Map labels) { + return new SecretBuilder().withNewMetadata().withName(name).withLabels(labels).endMetadata().build(); + } + +} diff --git a/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/VisibleFabric8ConfigMapPropertySourceLocator.java b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/VisibleFabric8ConfigMapPropertySourceLocator.java new file mode 100644 index 0000000000..7dfdfa566f --- /dev/null +++ b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/VisibleFabric8ConfigMapPropertySourceLocator.java @@ -0,0 +1,36 @@ +/* + * Copyright 2013-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.fabric8.config; + +import io.fabric8.kubernetes.client.KubernetesClient; + +import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider; +import org.springframework.cloud.kubernetes.commons.config.ConfigMapConfigProperties; + +/** + * Only needed to make Fabric8ConfigMapPropertySourceLocator visible for testing purposes + * + * @author wind57 + */ +public class VisibleFabric8ConfigMapPropertySourceLocator extends Fabric8ConfigMapPropertySourceLocator { + + public VisibleFabric8ConfigMapPropertySourceLocator(KubernetesClient client, ConfigMapConfigProperties properties, + KubernetesNamespaceProvider provider) { + super(client, properties, provider); + } + +} diff --git a/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/VisibleFabric8SecretsPropertySourceLocator.java b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/VisibleFabric8SecretsPropertySourceLocator.java new file mode 100644 index 0000000000..d0278e4d1f --- /dev/null +++ b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/VisibleFabric8SecretsPropertySourceLocator.java @@ -0,0 +1,36 @@ +/* + * Copyright 2013-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.fabric8.config; + +import io.fabric8.kubernetes.client.KubernetesClient; + +import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider; +import org.springframework.cloud.kubernetes.commons.config.SecretsConfigProperties; + +/** + * Only needed to make Fabric8SecretsPropertySourceLocator visible for testing purposes + * + * @author wind57 + */ +public class VisibleFabric8SecretsPropertySourceLocator extends Fabric8SecretsPropertySourceLocator { + + public VisibleFabric8SecretsPropertySourceLocator(KubernetesClient client, SecretsConfigProperties properties, + KubernetesNamespaceProvider provider) { + super(client, properties, provider); + } + +} diff --git a/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/EventReloadConfigMapTest.java b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/EventReloadConfigMapTest.java new file mode 100644 index 0000000000..d14efbabab --- /dev/null +++ b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/EventReloadConfigMapTest.java @@ -0,0 +1,215 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.fabric8.config.reload_it; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.ConfigMapList; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.dsl.MixedOperation; +import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation; +import io.fabric8.kubernetes.client.dsl.Resource; +import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider; +import org.springframework.cloud.kubernetes.commons.config.ConfigMapConfigProperties; +import org.springframework.cloud.kubernetes.commons.config.RetryProperties; +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties; +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy; +import org.springframework.cloud.kubernetes.fabric8.config.Fabric8ConfigMapPropertySourceLocator; +import org.springframework.cloud.kubernetes.fabric8.config.VisibleFabric8ConfigMapPropertySourceLocator; +import org.springframework.cloud.kubernetes.fabric8.config.reload.Fabric8EventBasedConfigMapChangeDetector; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.core.env.AbstractEnvironment; +import org.springframework.core.env.PropertySource; +import org.springframework.mock.env.MockEnvironment; + +/** + * @author wind57 + */ +@SpringBootTest( + properties = { "spring.main.allow-bean-definition-overriding=true", + "logging.level.org.springframework.cloud.kubernetes.commons.config=debug" }, + classes = { EventReloadConfigMapTest.TestConfig.class }) +@EnableKubernetesMockClient(crud = true) +@ExtendWith(OutputCaptureExtension.class) +public class EventReloadConfigMapTest { + + private static final boolean FAIL_FAST = false; + + private static final String CONFIG_MAP_NAME = "mine"; + + private static final String NAMESPACE = "spring-k8s"; + + private static KubernetesClient kubernetesClient; + + private static final boolean[] strategyCalled = new boolean[] { false }; + + @BeforeAll + static void beforeAll() { + + kubernetesClient = Mockito.spy(kubernetesClient); + kubernetesClient.getConfiguration().setRequestRetryBackoffLimit(0); + + ConfigMap configMapOne = configMap(CONFIG_MAP_NAME, Map.of()); + + // for the informer, when it starts + kubernetesClient.configMaps().inNamespace(NAMESPACE).resource(configMapOne).create(); + } + + @Test + @SuppressWarnings({ "unchecked" }) + void test(CapturedOutput output) { + + // we need to create this one before mocking calls + NonNamespaceOperation> operation = kubernetesClient.configMaps() + .inNamespace(NAMESPACE); + + // makes sure that when 'onEvent' is triggered (because we added a config map) + // the call to /api/v1/namespaces/spring-k8s/configmaps will fail with an + // Exception + MixedOperation> mixedOperation = Mockito + .mock(MixedOperation.class); + NonNamespaceOperation> mockedOperation = Mockito + .mock(NonNamespaceOperation.class); + Mockito.when(kubernetesClient.configMaps()).thenReturn(mixedOperation); + Mockito.when(mixedOperation.inNamespace(NAMESPACE)).thenReturn(mockedOperation); + Mockito.when(mockedOperation.list()).thenThrow(new RuntimeException("failed in reading configmap")); + + // create a different configmap that triggers even based reloading. + // the one we create, will trigger a call to + // /api/v1/namespaces/spring-k8s/configmaps + // that we mocked above to fail. + ConfigMap configMapTwo = configMap("not" + CONFIG_MAP_NAME, Map.of("a", "b")); + operation.resource(configMapTwo).create(); + + // we fail while reading 'configMapTwo' + Awaitility.await().atMost(Duration.ofSeconds(10)).pollInterval(Duration.ofSeconds(1)).until(() -> { + boolean one = output.getOut().contains("failure in reading named sources"); + boolean two = output.getOut() + .contains("there was an error while reading config maps/secrets, no reload will happen"); + boolean three = output.getOut() + .contains("reloadable condition was not satisfied, reload will not be triggered"); + boolean updateStrategyNotCalled = !strategyCalled[0]; + return one && two && three && updateStrategyNotCalled; + }); + + // reset the mock and replace our configmap with some data, so that reload + // is triggered + Mockito.reset(kubernetesClient); + ConfigMap configMapOne = configMap(CONFIG_MAP_NAME, Map.of("a", "b")); + operation.resource(configMapOne).replace(); + + // it passes while reading 'configMapThatWillPass' + Awaitility.await() + .atMost(Duration.ofSeconds(10)) + .pollInterval(Duration.ofSeconds(1)) + .until(() -> strategyCalled[0]); + } + + private static ConfigMap configMap(String name, Map data) { + return new ConfigMapBuilder().withNewMetadata().withName(name).endMetadata().withData(data).build(); + } + + @TestConfiguration + static class TestConfig { + + @Bean + @Primary + Fabric8EventBasedConfigMapChangeDetector fabric8EventBasedSecretsChangeDetector(AbstractEnvironment environment, + ConfigReloadProperties configReloadProperties, ConfigurationUpdateStrategy configurationUpdateStrategy, + Fabric8ConfigMapPropertySourceLocator fabric8ConfigMapPropertySourceLocator, + KubernetesNamespaceProvider namespaceProvider) { + return new Fabric8EventBasedConfigMapChangeDetector(environment, configReloadProperties, kubernetesClient, + configurationUpdateStrategy, fabric8ConfigMapPropertySourceLocator, namespaceProvider); + } + + @Bean + @Primary + AbstractEnvironment environment() { + MockEnvironment mockEnvironment = new MockEnvironment(); + mockEnvironment.setProperty("spring.cloud.kubernetes.client.namespace", NAMESPACE); + + // simulate that environment already has a Fabric8ConfigMapPropertySource, + // otherwise we can't properly test reload functionality + ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(), + List.of(), Map.of(), true, CONFIG_MAP_NAME, NAMESPACE, false, true, true, RetryProperties.DEFAULT); + KubernetesNamespaceProvider namespaceProvider = new KubernetesNamespaceProvider(mockEnvironment); + + PropertySource propertySource = new VisibleFabric8ConfigMapPropertySourceLocator(kubernetesClient, + configMapConfigProperties, namespaceProvider) + .locate(mockEnvironment); + + mockEnvironment.getPropertySources().addFirst(propertySource); + return mockEnvironment; + } + + @Bean + @Primary + ConfigReloadProperties configReloadProperties() { + return new ConfigReloadProperties(true, true, false, ConfigReloadProperties.ReloadStrategy.REFRESH, + ConfigReloadProperties.ReloadDetectionMode.EVENT, Duration.ofMillis(2000), Set.of(NAMESPACE), false, + Duration.ofSeconds(2)); + } + + @Bean + @Primary + ConfigMapConfigProperties configMapConfigProperties() { + return new ConfigMapConfigProperties(true, List.of(), List.of(), Map.of(), true, CONFIG_MAP_NAME, NAMESPACE, + false, true, FAIL_FAST, RetryProperties.DEFAULT); + } + + @Bean + @Primary + KubernetesNamespaceProvider namespaceProvider(AbstractEnvironment environment) { + return new KubernetesNamespaceProvider(environment); + } + + @Bean + @Primary + ConfigurationUpdateStrategy configurationUpdateStrategy() { + return new ConfigurationUpdateStrategy("to-console", () -> { + strategyCalled[0] = true; + }); + } + + @Bean + @Primary + Fabric8ConfigMapPropertySourceLocator fabric8ConfigMapPropertySourceLocator( + ConfigMapConfigProperties configMapConfigProperties, KubernetesNamespaceProvider namespaceProvider) { + return new VisibleFabric8ConfigMapPropertySourceLocator(kubernetesClient, configMapConfigProperties, + namespaceProvider); + } + + } + +} diff --git a/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/EventReloadSecretTest.java b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/EventReloadSecretTest.java new file mode 100644 index 0000000000..546ae964b4 --- /dev/null +++ b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/EventReloadSecretTest.java @@ -0,0 +1,222 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.fabric8.config.reload_it; + +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import io.fabric8.kubernetes.api.model.Secret; +import io.fabric8.kubernetes.api.model.SecretBuilder; +import io.fabric8.kubernetes.api.model.SecretList; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.dsl.MixedOperation; +import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation; +import io.fabric8.kubernetes.client.dsl.Resource; +import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider; +import org.springframework.cloud.kubernetes.commons.config.RetryProperties; +import org.springframework.cloud.kubernetes.commons.config.SecretsConfigProperties; +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties; +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy; +import org.springframework.cloud.kubernetes.fabric8.config.Fabric8SecretsPropertySourceLocator; +import org.springframework.cloud.kubernetes.fabric8.config.VisibleFabric8SecretsPropertySourceLocator; +import org.springframework.cloud.kubernetes.fabric8.config.reload.Fabric8EventBasedSecretsChangeDetector; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.core.env.AbstractEnvironment; +import org.springframework.core.env.PropertySource; +import org.springframework.mock.env.MockEnvironment; + +/** + * @author wind57 + */ +@SpringBootTest( + properties = { "spring.main.allow-bean-definition-overriding=true", + "logging.level.org.springframework.cloud.kubernetes.commons.config=debug" }, + classes = { EventReloadSecretTest.TestConfig.class }) +@EnableKubernetesMockClient(crud = true) +@ExtendWith(OutputCaptureExtension.class) + +class EventReloadSecretTest { + + private static final boolean FAIL_FAST = false; + + private static final String SECRET_NAME = "mine"; + + private static final String NAMESPACE = "spring-k8s"; + + private static KubernetesClient kubernetesClient; + + private static final boolean[] strategyCalled = new boolean[] { false }; + + @BeforeAll + static void beforeAll() { + + kubernetesClient = Mockito.spy(kubernetesClient); + kubernetesClient.getConfiguration().setRequestRetryBackoffLimit(0); + + Secret secretOne = secret(SECRET_NAME, Map.of()); + + // for the informer, when it starts + kubernetesClient.secrets().inNamespace(NAMESPACE).resource(secretOne).create(); + } + + @Test + @SuppressWarnings({ "unchecked" }) + void test(CapturedOutput output) { + + // we need to create this one before mocking calls + NonNamespaceOperation> operation = kubernetesClient.secrets() + .inNamespace(NAMESPACE); + + // makes sure that when 'onEvent' is triggered (because we added a config map) + // the call to /api/v1/namespaces/spring-k8s/secrets will fail with an + // Exception + MixedOperation> mixedOperation = Mockito.mock(MixedOperation.class); + NonNamespaceOperation> mockedOperation = Mockito + .mock(NonNamespaceOperation.class); + Mockito.when(kubernetesClient.secrets()).thenReturn(mixedOperation); + Mockito.when(mixedOperation.inNamespace(NAMESPACE)).thenReturn(mockedOperation); + Mockito.when(mockedOperation.list()).thenThrow(new RuntimeException("failed in reading secret")); + + // create a different secret that triggers even based reloading. + // the one we create, will trigger a call to + // /api/v1/namespaces/spring-k8s/secrets + // that we mocked above to fail. + Secret secretTwo = secret("not" + SECRET_NAME, Map.of("a", "b")); + operation.resource(secretTwo).create(); + + // we fail while reading 'secretTwo' + Awaitility.await().atMost(Duration.ofSeconds(10)).pollInterval(Duration.ofSeconds(1)).until(() -> { + boolean one = output.getOut().contains("failure in reading named sources"); + boolean two = output.getOut() + .contains("there was an error while reading config maps/secrets, no reload will happen"); + boolean three = output.getOut() + .contains("reloadable condition was not satisfied, reload will not be triggered"); + boolean updateStrategyNotCalled = !strategyCalled[0]; + return one && two && three && updateStrategyNotCalled; + }); + + // reset the mock and replace our secret with some data, so that reload + // is triggered + Mockito.reset(kubernetesClient); + Secret secretOne = secret(SECRET_NAME, Map.of("a", "b")); + operation.resource(secretOne).replace(); + + // it passes while reading 'secretOne' + Awaitility.await() + .atMost(Duration.ofSeconds(10)) + .pollInterval(Duration.ofSeconds(1)) + .until(() -> strategyCalled[0]); + } + + private static Secret secret(String name, Map data) { + Map encoded = data.entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, + e -> new String(Base64.getEncoder().encode(e.getValue().getBytes())))); + return new SecretBuilder().withNewMetadata().withName(name).endMetadata().withData(encoded).build(); + } + + @TestConfiguration + static class TestConfig { + + @Bean + @Primary + Fabric8EventBasedSecretsChangeDetector fabric8EventBasedSecretsChangeDetector(AbstractEnvironment environment, + ConfigReloadProperties configReloadProperties, ConfigurationUpdateStrategy configurationUpdateStrategy, + Fabric8SecretsPropertySourceLocator fabric8SecretsPropertySourceLocator, + KubernetesNamespaceProvider namespaceProvider) { + return new Fabric8EventBasedSecretsChangeDetector(environment, configReloadProperties, kubernetesClient, + configurationUpdateStrategy, fabric8SecretsPropertySourceLocator, namespaceProvider); + } + + @Bean + @Primary + AbstractEnvironment environment() { + MockEnvironment mockEnvironment = new MockEnvironment(); + mockEnvironment.setProperty("spring.cloud.kubernetes.client.namespace", NAMESPACE); + + // simulate that environment already has a + // Fabric8SecretsPropertySourceLocator, + // otherwise we can't properly test reload functionality + SecretsConfigProperties secretsConfigProperties = new SecretsConfigProperties(true, Map.of(), List.of(), + List.of(), true, SECRET_NAME, NAMESPACE, false, true, true, RetryProperties.DEFAULT); + KubernetesNamespaceProvider namespaceProvider = new KubernetesNamespaceProvider(mockEnvironment); + + PropertySource propertySource = new VisibleFabric8SecretsPropertySourceLocator(kubernetesClient, + secretsConfigProperties, namespaceProvider) + .locate(mockEnvironment); + + mockEnvironment.getPropertySources().addFirst(propertySource); + return mockEnvironment; + } + + @Bean + @Primary + ConfigReloadProperties configReloadProperties() { + return new ConfigReloadProperties(true, true, false, ConfigReloadProperties.ReloadStrategy.REFRESH, + ConfigReloadProperties.ReloadDetectionMode.EVENT, Duration.ofMillis(2000), Set.of(NAMESPACE), false, + Duration.ofSeconds(2)); + } + + @Bean + @Primary + SecretsConfigProperties secretsConfigProperties() { + return new SecretsConfigProperties(true, Map.of(), List.of(), List.of(), true, SECRET_NAME, NAMESPACE, + false, true, FAIL_FAST, RetryProperties.DEFAULT); + } + + @Bean + @Primary + KubernetesNamespaceProvider namespaceProvider(AbstractEnvironment environment) { + return new KubernetesNamespaceProvider(environment); + } + + @Bean + @Primary + ConfigurationUpdateStrategy configurationUpdateStrategy() { + return new ConfigurationUpdateStrategy("to-console", () -> { + strategyCalled[0] = true; + }); + } + + @Bean + @Primary + Fabric8SecretsPropertySourceLocator fabric8SecretsPropertySourceLocator( + SecretsConfigProperties secretsConfigProperties, KubernetesNamespaceProvider namespaceProvider) { + return new VisibleFabric8SecretsPropertySourceLocator(kubernetesClient, secretsConfigProperties, + namespaceProvider); + } + + } + +} diff --git a/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/PollingReloadConfigMapTest.java b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/PollingReloadConfigMapTest.java new file mode 100644 index 0000000000..372ca8be31 --- /dev/null +++ b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/PollingReloadConfigMapTest.java @@ -0,0 +1,205 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.fabric8.config.reload_it; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.ConfigMapListBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient; +import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider; +import org.springframework.cloud.kubernetes.commons.config.ConfigMapConfigProperties; +import org.springframework.cloud.kubernetes.commons.config.RetryProperties; +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties; +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy; +import org.springframework.cloud.kubernetes.commons.config.reload.PollingConfigMapChangeDetector; +import org.springframework.cloud.kubernetes.fabric8.config.Fabric8ConfigMapPropertySource; +import org.springframework.cloud.kubernetes.fabric8.config.Fabric8ConfigMapPropertySourceLocator; +import org.springframework.cloud.kubernetes.fabric8.config.VisibleFabric8ConfigMapPropertySourceLocator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.core.env.AbstractEnvironment; +import org.springframework.core.env.PropertySource; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +/** + * @author wind57 + */ +@SpringBootTest( + properties = { "spring.main.allow-bean-definition-overriding=true", + "logging.level.org.springframework.cloud.kubernetes.commons.config=debug" }, + classes = { PollingReloadConfigMapTest.TestConfig.class }) +@EnableKubernetesMockClient +@ExtendWith(OutputCaptureExtension.class) +class PollingReloadConfigMapTest { + + private static final boolean FAIL_FAST = false; + + private static final String CONFIG_MAP_NAME = "mine"; + + private static final String NAMESPACE = "spring-k8s"; + + private static KubernetesMockServer kubernetesMockServer; + + private static KubernetesClient kubernetesClient; + + private static final boolean[] strategyCalled = new boolean[] { false }; + + @BeforeAll + static void beforeAll() { + + kubernetesClient.getConfiguration().setRequestRetryBackoffLimit(0); + + // needed so that our environment is populated with 'something' + // this call is done in the method that returns the AbstractEnvironment + ConfigMap configMapOne = configMap(CONFIG_MAP_NAME, Map.of()); + ConfigMap configMapTwo = configMap(CONFIG_MAP_NAME, Map.of("a", "b")); + String path = "/api/v1/namespaces/spring-k8s/configmaps"; + kubernetesMockServer.expect() + .withPath(path) + .andReturn(200, new ConfigMapListBuilder().withItems(configMapOne).build()) + .once(); + + kubernetesMockServer.expect().withPath(path).andReturn(500, "Internal Server Error").once(); + + kubernetesMockServer.expect() + .withPath(path) + .andReturn(200, new ConfigMapListBuilder().withItems(configMapTwo).build()) + .once(); + } + + /** + *
+	 *     - we have a PropertySource in the environment
+	 *     - first polling cycle tries to read the sources from k8s and fails
+	 *     - second polling cycle reads sources from k8s and finds a change
+	 * 
+ */ + @Test + void test(CapturedOutput output) { + // we fail while reading 'configMapOne' + Awaitility.await().atMost(Duration.ofSeconds(10)).pollInterval(Duration.ofSeconds(1)).until(() -> { + boolean one = output.getOut().contains("failure in reading named sources"); + boolean two = output.getOut() + .contains("there was an error while reading config maps/secrets, no reload will happen"); + boolean three = output.getOut() + .contains("reloadable condition was not satisfied, reload will not be triggered"); + boolean updateStrategyNotCalled = !strategyCalled[0]; + return one && two && three && updateStrategyNotCalled; + }); + + // it passes while reading 'configMapTwo' + Awaitility.await() + .atMost(Duration.ofSeconds(10)) + .pollInterval(Duration.ofSeconds(1)) + .until(() -> strategyCalled[0]); + } + + private static ConfigMap configMap(String name, Map data) { + return new ConfigMapBuilder().withNewMetadata().withName(name).endMetadata().withData(data).build(); + } + + @TestConfiguration + static class TestConfig { + + @Bean + @Primary + PollingConfigMapChangeDetector pollingConfigMapChangeDetector(AbstractEnvironment environment, + ConfigReloadProperties configReloadProperties, ConfigurationUpdateStrategy configurationUpdateStrategy, + Fabric8ConfigMapPropertySourceLocator fabric8ConfigMapPropertySourceLocator) { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.initialize(); + return new PollingConfigMapChangeDetector(environment, configReloadProperties, configurationUpdateStrategy, + Fabric8ConfigMapPropertySource.class, fabric8ConfigMapPropertySourceLocator, scheduler); + } + + @Bean + @Primary + AbstractEnvironment environment() { + MockEnvironment mockEnvironment = new MockEnvironment(); + mockEnvironment.setProperty("spring.cloud.kubernetes.client.namespace", NAMESPACE); + + // simulate that environment already has a Fabric8ConfigMapPropertySource, + // otherwise we can't properly test reload functionality + ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(), + List.of(), Map.of(), true, CONFIG_MAP_NAME, NAMESPACE, false, true, true, RetryProperties.DEFAULT); + KubernetesNamespaceProvider namespaceProvider = new KubernetesNamespaceProvider(mockEnvironment); + + PropertySource propertySource = new VisibleFabric8ConfigMapPropertySourceLocator(kubernetesClient, + configMapConfigProperties, namespaceProvider) + .locate(mockEnvironment); + + mockEnvironment.getPropertySources().addFirst(propertySource); + return mockEnvironment; + } + + @Bean + @Primary + ConfigReloadProperties configReloadProperties() { + return new ConfigReloadProperties(true, true, false, ConfigReloadProperties.ReloadStrategy.REFRESH, + ConfigReloadProperties.ReloadDetectionMode.POLLING, Duration.ofMillis(2000), Set.of("non-default"), + false, Duration.ofSeconds(2)); + } + + @Bean + @Primary + ConfigMapConfigProperties configMapConfigProperties() { + return new ConfigMapConfigProperties(true, List.of(), List.of(), Map.of(), true, CONFIG_MAP_NAME, NAMESPACE, + false, true, FAIL_FAST, RetryProperties.DEFAULT); + } + + @Bean + @Primary + KubernetesNamespaceProvider namespaceProvider(AbstractEnvironment environment) { + return new KubernetesNamespaceProvider(environment); + } + + @Bean + @Primary + ConfigurationUpdateStrategy configurationUpdateStrategy() { + return new ConfigurationUpdateStrategy("to-console", () -> { + strategyCalled[0] = true; + }); + } + + @Bean + @Primary + Fabric8ConfigMapPropertySourceLocator fabric8ConfigMapPropertySourceLocator( + ConfigMapConfigProperties configMapConfigProperties, KubernetesNamespaceProvider namespaceProvider) { + return new VisibleFabric8ConfigMapPropertySourceLocator(kubernetesClient, configMapConfigProperties, + namespaceProvider); + } + + } + +} diff --git a/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/PollingReloadSecretTest.java b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/PollingReloadSecretTest.java new file mode 100644 index 0000000000..68667637ce --- /dev/null +++ b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/PollingReloadSecretTest.java @@ -0,0 +1,212 @@ +/* + * Copyright 2012-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.fabric8.config.reload_it; + +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import io.fabric8.kubernetes.api.model.Secret; +import io.fabric8.kubernetes.api.model.SecretBuilder; +import io.fabric8.kubernetes.api.model.SecretListBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient; +import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider; +import org.springframework.cloud.kubernetes.commons.config.RetryProperties; +import org.springframework.cloud.kubernetes.commons.config.SecretsConfigProperties; +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties; +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy; +import org.springframework.cloud.kubernetes.commons.config.reload.PollingSecretsChangeDetector; +import org.springframework.cloud.kubernetes.fabric8.config.Fabric8SecretsPropertySource; +import org.springframework.cloud.kubernetes.fabric8.config.Fabric8SecretsPropertySourceLocator; +import org.springframework.cloud.kubernetes.fabric8.config.VisibleFabric8SecretsPropertySourceLocator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.core.env.AbstractEnvironment; +import org.springframework.core.env.PropertySource; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +/** + * @author wind57 + */ +@SpringBootTest( + properties = { "spring.main.allow-bean-definition-overriding=true", + "logging.level.org.springframework.cloud.kubernetes.commons.config=debug" }, + classes = { PollingReloadSecretTest.TestConfig.class }) +@EnableKubernetesMockClient +@ExtendWith(OutputCaptureExtension.class) + +public class PollingReloadSecretTest { + + private static final boolean FAIL_FAST = false; + + private static final String SECRET_NAME = "mine"; + + private static final String NAMESPACE = "spring-k8s"; + + private static KubernetesMockServer kubernetesMockServer; + + private static KubernetesClient kubernetesClient; + + private static final boolean[] strategyCalled = new boolean[] { false }; + + @BeforeAll + static void beforeAll() { + + kubernetesClient.getConfiguration().setRequestRetryBackoffLimit(0); + + // needed so that our environment is populated with 'something' + // this call is done in the method that returns the AbstractEnvironment + Secret secretOne = secret(SECRET_NAME, Map.of()); + Secret secretTwo = secret(SECRET_NAME, Map.of("a", "b")); + String path = "/api/v1/namespaces/spring-k8s/secrets"; + kubernetesMockServer.expect() + .withPath(path) + .andReturn(200, new SecretListBuilder().withItems(secretOne).build()) + .once(); + + kubernetesMockServer.expect().withPath(path).andReturn(500, "Internal Server Error").once(); + + kubernetesMockServer.expect() + .withPath(path) + .andReturn(200, new SecretListBuilder().withItems(secretTwo).build()) + .once(); + } + + /** + *
+	 *     - we have a PropertySource in the environment
+	 *     - first polling cycle tries to read the sources from k8s and fails
+	 *     - second polling cycle reads sources from k8s and finds a change
+	 * 
+ */ + @Test + void test(CapturedOutput output) { + // we fail while reading 'secretOne' + Awaitility.await().atMost(Duration.ofSeconds(10)).pollInterval(Duration.ofSeconds(1)).until(() -> { + boolean one = output.getOut().contains("failure in reading named sources"); + boolean two = output.getOut() + .contains("there was an error while reading config maps/secrets, no reload will happen"); + boolean three = output.getOut() + .contains("reloadable condition was not satisfied, reload will not be triggered"); + boolean updateStrategyNotCalled = !strategyCalled[0]; + return one && two && three && updateStrategyNotCalled; + }); + + // it passes while reading 'secretTwo' + Awaitility.await() + .atMost(Duration.ofSeconds(10)) + .pollInterval(Duration.ofSeconds(1)) + .until(() -> strategyCalled[0]); + } + + private static Secret secret(String name, Map data) { + Map encoded = data.entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, + e -> new String(Base64.getEncoder().encode(e.getValue().getBytes())))); + return new SecretBuilder().withNewMetadata().withName(name).endMetadata().withData(encoded).build(); + } + + @TestConfiguration + static class TestConfig { + + @Bean + @Primary + PollingSecretsChangeDetector pollingSecretsChangeDetector(AbstractEnvironment environment, + ConfigReloadProperties configReloadProperties, ConfigurationUpdateStrategy configurationUpdateStrategy, + Fabric8SecretsPropertySourceLocator fabric8SecretsPropertySourceLocator) { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.initialize(); + return new PollingSecretsChangeDetector(environment, configReloadProperties, configurationUpdateStrategy, + Fabric8SecretsPropertySource.class, fabric8SecretsPropertySourceLocator, scheduler); + } + + @Bean + @Primary + AbstractEnvironment environment() { + MockEnvironment mockEnvironment = new MockEnvironment(); + mockEnvironment.setProperty("spring.cloud.kubernetes.client.namespace", NAMESPACE); + + // simulate that environment already has a Fabric8SecretsPropertySource, + // otherwise we can't properly test reload functionality + SecretsConfigProperties secretsConfigProperties = new SecretsConfigProperties(true, Map.of(), List.of(), + List.of(), true, SECRET_NAME, NAMESPACE, false, true, true, RetryProperties.DEFAULT); + KubernetesNamespaceProvider namespaceProvider = new KubernetesNamespaceProvider(mockEnvironment); + + PropertySource propertySource = new VisibleFabric8SecretsPropertySourceLocator(kubernetesClient, + secretsConfigProperties, namespaceProvider) + .locate(mockEnvironment); + + mockEnvironment.getPropertySources().addFirst(propertySource); + return mockEnvironment; + } + + @Bean + @Primary + ConfigReloadProperties configReloadProperties() { + return new ConfigReloadProperties(true, true, true, ConfigReloadProperties.ReloadStrategy.REFRESH, + ConfigReloadProperties.ReloadDetectionMode.POLLING, Duration.ofMillis(2000), Set.of("non-default"), + false, Duration.ofSeconds(2)); + } + + @Bean + @Primary + SecretsConfigProperties secretsConfigProperties() { + return new SecretsConfigProperties(true, Map.of(), List.of(), List.of(), true, SECRET_NAME, NAMESPACE, + false, true, FAIL_FAST, RetryProperties.DEFAULT); + } + + @Bean + @Primary + KubernetesNamespaceProvider namespaceProvider(AbstractEnvironment environment) { + return new KubernetesNamespaceProvider(environment); + } + + @Bean + @Primary + ConfigurationUpdateStrategy configurationUpdateStrategy() { + return new ConfigurationUpdateStrategy("to-console", () -> { + strategyCalled[0] = true; + }); + } + + @Bean + @Primary + Fabric8SecretsPropertySourceLocator fabric8SecretsPropertySourceLocator( + SecretsConfigProperties secretsConfigProperties, KubernetesNamespaceProvider namespaceProvider) { + return new VisibleFabric8SecretsPropertySourceLocator(kubernetesClient, secretsConfigProperties, + namespaceProvider); + } + + } + +} From 20529e6e678bc9aaad380b5fc933b3cbd6e488ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Nov 2024 11:01:33 -0500 Subject: [PATCH 08/16] Bump antora from 3.2.0-alpha.4 to 3.2.0-alpha.6 in /docs (#1696) Bumps [antora](https://gitlab.com/antora/antora) from 3.2.0-alpha.4 to 3.2.0-alpha.6. - [Changelog](https://gitlab.com/antora/antora/blob/main/CHANGELOG.adoc) - [Commits](https://gitlab.com/antora/antora/compare/v3.2.0-alpha.4...v3.2.0-alpha.6) --- updated-dependencies: - dependency-name: antora dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/package.json b/docs/package.json index 4ba0c41216..d56285b524 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "dependencies": { - "antora": "3.2.0-alpha.4", + "antora": "3.2.0-alpha.6", "@antora/atlas-extension": "1.0.0-alpha.2", "@antora/collector-extension": "1.0.0-beta.5", "@asciidoctor/tabs": "1.0.0-beta.6", From a7a70b6756d7d096c42175306e877de9d2cb3088 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Nov 2024 11:02:59 -0500 Subject: [PATCH 09/16] Bump antora from 3.2.0-alpha.4 to 3.2.0-alpha.6 in /docs (#1695) Bumps [antora](https://gitlab.com/antora/antora) from 3.2.0-alpha.4 to 3.2.0-alpha.6. - [Changelog](https://gitlab.com/antora/antora/blob/main/CHANGELOG.adoc) - [Commits](https://gitlab.com/antora/antora/compare/v3.2.0-alpha.4...v3.2.0-alpha.6) --- updated-dependencies: - dependency-name: antora dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/package.json b/docs/package.json index 4ba0c41216..d56285b524 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "dependencies": { - "antora": "3.2.0-alpha.4", + "antora": "3.2.0-alpha.6", "@antora/atlas-extension": "1.0.0-alpha.2", "@antora/collector-extension": "1.0.0-beta.5", "@asciidoctor/tabs": "1.0.0-beta.6", From 76426ca66aef539d69f9166a938b0f00504e1431 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Nov 2024 11:05:06 -0500 Subject: [PATCH 10/16] Bump @springio/antora-extensions from 1.11.1 to 1.14.2 in /docs (#1694) Bumps [@springio/antora-extensions](https://github.com/spring-io/antora-extensions) from 1.11.1 to 1.14.2. - [Changelog](https://github.com/spring-io/antora-extensions/blob/main/CHANGELOG.adoc) - [Commits](https://github.com/spring-io/antora-extensions/compare/v1.11.1...v1.14.2) --- updated-dependencies: - dependency-name: "@springio/antora-extensions" dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/package.json b/docs/package.json index d56285b524..224f675211 100644 --- a/docs/package.json +++ b/docs/package.json @@ -4,7 +4,7 @@ "@antora/atlas-extension": "1.0.0-alpha.2", "@antora/collector-extension": "1.0.0-beta.5", "@asciidoctor/tabs": "1.0.0-beta.6", - "@springio/antora-extensions": "1.11.1", + "@springio/antora-extensions": "1.14.2", "@springio/asciidoctor-extensions": "1.0.0-alpha.14" } } From e7899001fa7df84a2712b94b6f4f19a2416e607d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Nov 2024 11:05:35 -0500 Subject: [PATCH 11/16] Bump @springio/antora-extensions from 1.11.1 to 1.14.2 in /docs (#1692) Bumps [@springio/antora-extensions](https://github.com/spring-io/antora-extensions) from 1.11.1 to 1.14.2. - [Changelog](https://github.com/spring-io/antora-extensions/blob/main/CHANGELOG.adoc) - [Commits](https://github.com/spring-io/antora-extensions/compare/v1.11.1...v1.14.2) --- updated-dependencies: - dependency-name: "@springio/antora-extensions" dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/package.json b/docs/package.json index d56285b524..224f675211 100644 --- a/docs/package.json +++ b/docs/package.json @@ -4,7 +4,7 @@ "@antora/atlas-extension": "1.0.0-alpha.2", "@antora/collector-extension": "1.0.0-beta.5", "@asciidoctor/tabs": "1.0.0-beta.6", - "@springio/antora-extensions": "1.11.1", + "@springio/antora-extensions": "1.14.2", "@springio/asciidoctor-extensions": "1.0.0-alpha.14" } } From 00c91ada154429acf74286bb2fcb7b1f177ebf8f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Nov 2024 11:06:05 -0500 Subject: [PATCH 12/16] Bump actions/checkout from 3 to 4 (#1690) Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/deploy-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index be4b92dfc0..833dcbe09c 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -16,7 +16,7 @@ jobs: # if: github.repository_owner == 'spring-cloud' steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: docs-build fetch-depth: 1 From ac236777c3d085314f4980db727732ed39973cd5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Nov 2024 11:06:36 -0500 Subject: [PATCH 13/16] Bump com.spotify:dockerfile-maven-plugin from 1.4.12 to 1.4.13 (#1689) Bumps [com.spotify:dockerfile-maven-plugin](https://github.com/spotify/dockerfile-maven) from 1.4.12 to 1.4.13. - [Release notes](https://github.com/spotify/dockerfile-maven/releases) - [Changelog](https://github.com/spotify/dockerfile-maven/blob/master/CHANGELOG.md) - [Commits](https://github.com/spotify/dockerfile-maven/compare/v1.4.12...v1.4.13) --- updated-dependencies: - dependency-name: com.spotify:dockerfile-maven-plugin dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../spring-cloud-kubernetes-configserver/pom.xml | 2 +- .../spring-cloud-kubernetes-configuration-watcher/pom.xml | 2 +- .../spring-cloud-kubernetes-discoveryserver/pom.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configserver/pom.xml b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configserver/pom.xml index 5f27f256be..2ff07e26e7 100644 --- a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configserver/pom.xml +++ b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configserver/pom.xml @@ -60,7 +60,7 @@ com.spotify dockerfile-maven-plugin - 1.4.12 + 1.4.13 ${docker.registry.organization}/${artifactId} ${project.version} diff --git a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/pom.xml b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/pom.xml index aad5c6bcb0..5fef38ccdc 100644 --- a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/pom.xml +++ b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-configuration-watcher/pom.xml @@ -79,7 +79,7 @@ com.spotify dockerfile-maven-plugin - 1.4.12 + 1.4.13 ${docker.registry.organization}/${artifactId} ${project.version} diff --git a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-discoveryserver/pom.xml b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-discoveryserver/pom.xml index 90bc70d489..489822fb70 100644 --- a/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-discoveryserver/pom.xml +++ b/spring-cloud-kubernetes-controllers/spring-cloud-kubernetes-discoveryserver/pom.xml @@ -59,7 +59,7 @@ com.spotify dockerfile-maven-plugin - 1.4.12 + 1.4.13 ${docker.registry.organization}/${artifactId} ${project.version} From 2ca6818a393e5e486693e025156bd0000cf6e77e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Nov 2024 11:07:04 -0500 Subject: [PATCH 14/16] Bump actions/github-script from 6 to 7 (#1688) Bumps [actions/github-script](https://github.com/actions/github-script) from 6 to 7. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/github-script dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/maven.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/maven.yaml b/.github/workflows/maven.yaml index 176a6df0f6..783b731036 100644 --- a/.github/workflows/maven.yaml +++ b/.github/workflows/maven.yaml @@ -41,7 +41,7 @@ jobs: uses: ./.github/workflows/composites/cache - name: Show caches - uses: actions/github-script@v6 + uses: actions/github-script@v7 with: script: | const caches = await github.rest.actions.getActionsCacheList({ From 93479f35cf9a7a9d249bf357c9988c9297e8042c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 Nov 2024 11:09:03 -0500 Subject: [PATCH 15/16] Bump andstor/file-existence-action from 2 to 3 (#1686) Bumps [andstor/file-existence-action](https://github.com/andstor/file-existence-action) from 2 to 3. - [Release notes](https://github.com/andstor/file-existence-action/releases) - [Commits](https://github.com/andstor/file-existence-action/compare/v2...v3) --- updated-dependencies: - dependency-name: andstor/file-existence-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/maven.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/maven.yaml b/.github/workflows/maven.yaml index 783b731036..6843a60432 100644 --- a/.github/workflows/maven.yaml +++ b/.github/workflows/maven.yaml @@ -63,7 +63,7 @@ jobs: - name: check test times cache exists id: check_files - uses: andstor/file-existence-action@v2 + uses: andstor/file-existence-action@v3 with: files: /tmp/sorted.txt From 9139943f385aca7c610e4395d1f74066f0b940e1 Mon Sep 17 00:00:00 2001 From: cloudchamb3r Date: Mon, 25 Nov 2024 22:40:00 +0900 Subject: [PATCH 16/16] Update k8s how-to documentation link (#1801) --- docs/modules/ROOT/pages/spring-cloud-kubernetes.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/ROOT/pages/spring-cloud-kubernetes.adoc b/docs/modules/ROOT/pages/spring-cloud-kubernetes.adoc index 1424061342..02b1345f3b 100644 --- a/docs/modules/ROOT/pages/spring-cloud-kubernetes.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-kubernetes.adoc @@ -6,7 +6,7 @@ This reference guide covers how to use Spring Cloud Kubernetes. [[why-do-you-need-spring-cloud-kubernetes]] == Why do you need Spring Cloud Kubernetes? -Spring Cloud Kubernetes provides implementations of well known Spring Cloud interfaces allowing developers to build and run Spring Cloud applications on Kubernetes. While this project may be useful to you when building a cloud native application, it is also not a requirement in order to deploy a Spring Boot app on Kubernetes. If you are just getting started in your journey to running your Spring Boot app on Kubernetes you can accomplish a lot with nothing more than a basic Spring Boot app and Kubernetes itself. To learn more, you can get started by reading the https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#cloud-deployment-kubernetes[Spring Boot reference documentation for deploying to Kubernetes ] and also working through the workshop material https://hackmd.io/@ryanjbaxter/spring-on-k8s-workshop[Spring and Kubernetes]. +Spring Cloud Kubernetes provides implementations of well known Spring Cloud interfaces allowing developers to build and run Spring Cloud applications on Kubernetes. While this project may be useful to you when building a cloud native application, it is also not a requirement in order to deploy a Spring Boot app on Kubernetes. If you are just getting started in your journey to running your Spring Boot app on Kubernetes you can accomplish a lot with nothing more than a basic Spring Boot app and Kubernetes itself. To learn more, you can get started by reading the https://docs.spring.io/spring-boot/how-to/deployment/cloud.html#howto.deployment.cloud.kubernetes[Spring Boot reference documentation for deploying to Kubernetes ] and also working through the workshop material https://hackmd.io/@ryanjbaxter/spring-on-k8s-workshop[Spring and Kubernetes].