diff --git a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload/KubernetesClientEventBasedSecretsChangeDetectorTests.java b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload/KubernetesClientEventBasedSecretsChangeDetectorTests.java index c4fa1d326e..98bfef6cf1 100644 --- a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload/KubernetesClientEventBasedSecretsChangeDetectorTests.java +++ b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload/KubernetesClientEventBasedSecretsChangeDetectorTests.java @@ -183,8 +183,7 @@ void equalsOne() { } /** - * - left is empty map - * - right is null + * - left is empty map - right is null * * treat as equal, that is: no change */ @@ -198,8 +197,7 @@ void equalsTwo() { } /** - * - left is empty map - * - right is null + * - left is empty map - right is null * * treat as equal, that is: no change */ @@ -213,8 +211,7 @@ void equalsThree() { } /** - * - left is null - * - right is empty map + * - left is null - right is empty map * * treat as equal, that is: no change */ @@ -228,8 +225,7 @@ void equalsFour() { } /** - * - left is empty map - * - right is empty map + * - left is empty map - right is empty map * * treat as equal, that is: no change */ @@ -243,8 +239,7 @@ void equalsFive() { } /** - * - left is empty map - * - right is [1, b] + * - left is empty map - right is [1, b] * * treat as non-equal, that is change */ @@ -258,8 +253,7 @@ void equalsSix() { } /** - * - left is [1, a] - * - right is [1, b] + * - left is [1, a] - right is [1, b] * * treat as non-equal, that is change */ @@ -273,8 +267,7 @@ void equalsSeven() { } /** - * - left is [1, a, 2 aa] - * - right is [1, b, 2, aa] + * - left is [1, a, 2 aa] - right is [1, b, 2, aa] * * treat as non-equal, that is change */ diff --git a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/ConfigMapPropertySourceLocator.java b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/ConfigMapPropertySourceLocator.java index 40a8f382ac..1bfcf39e06 100644 --- a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/ConfigMapPropertySourceLocator.java +++ b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/ConfigMapPropertySourceLocator.java @@ -99,6 +99,7 @@ public Collection> locateCollection(Environment environment) { private void addPropertySourcesFromPaths(Environment environment, CompositePropertySource composite) { Set uniquePaths = new LinkedHashSet<>(properties.paths()); + LOG.debug("paths property sources : " + uniquePaths); uniquePaths.stream().map(Paths::get).filter(p -> { boolean exists = Files.exists(p); if (!exists) { @@ -139,7 +140,8 @@ private void addPropertySourceIfNeeded(Function> con LOG.warn("Property source: " + name + "will be ignored because no properties could be found"); } else { - composite.addFirstPropertySource(new MapPropertySource(name, map)); + LOG.debug("will add file-based property source : " + name); + composite.addFirstPropertySource(new MountConfigMapPropertySource(name, map)); } } diff --git a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/MountConfigMapPropertySource.java b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/MountConfigMapPropertySource.java index 1d9b0b7fc8..7cbffac4c0 100644 --- a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/MountConfigMapPropertySource.java +++ b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/MountConfigMapPropertySource.java @@ -20,6 +20,9 @@ import org.springframework.core.env.MapPropertySource; +/** + * @author wind57 + */ public final class MountConfigMapPropertySource extends MapPropertySource { public MountConfigMapPropertySource(String name, Map source) { 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 9659017669..d9b0f933fa 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 @@ -26,6 +26,7 @@ import org.springframework.cloud.bootstrap.config.BootstrapPropertySource; import org.springframework.cloud.bootstrap.config.PropertySourceLocator; +import org.springframework.cloud.kubernetes.commons.config.MountConfigMapPropertySource; import org.springframework.core.env.CompositePropertySource; import org.springframework.core.env.ConfigurableEnvironment; import org.springframework.core.env.MapPropertySource; @@ -73,8 +74,8 @@ public static > List findPropertySources(Class List> sources = environment.getPropertySources().stream() .collect(Collectors.toCollection(ArrayList::new)); - LOG.debug(() -> "environment: " + environment); - LOG.debug(() -> "environment sources: " + sources); + LOG.debug(() -> "environment from findPropertySources: " + environment); + LOG.debug(() -> "environment sources from findPropertySources : " + sources); while (!sources.isEmpty()) { PropertySource source = sources.remove(0); @@ -84,14 +85,24 @@ public static > List findPropertySources(Class else if (sourceClass.isInstance(source)) { managedSources.add(sourceClass.cast(source)); } + else if (source instanceof MountConfigMapPropertySource mountConfigMapPropertySource) { + // we know that the type is correct here + managedSources.add((S) mountConfigMapPropertySource); + } else if (source instanceof BootstrapPropertySource bootstrapPropertySource) { PropertySource propertySource = bootstrapPropertySource.getDelegate(); + LOG.debug(() -> "bootstrap delegate class : " + propertySource.getClass()); if (sourceClass.isInstance(propertySource)) { sources.add(propertySource); } + else if (propertySource instanceof MountConfigMapPropertySource mountConfigMapPropertySource) { + // we know that the type is correct here + managedSources.add((S) mountConfigMapPropertySource); + } } } + LOG.debug(() -> "findPropertySources : " + managedSources.stream().map(PropertySource::getName).toList()); return managedSources; } @@ -123,17 +134,14 @@ else if (propertySource instanceof CompositePropertySource source) { LOG.debug(() -> "Found property source that cannot be handled: " + propertySource.getClass()); } - LOG.debug(() -> "environment: " + environment); - LOG.debug(() -> "sources: " + result); + LOG.debug(() -> "environment from locateMapPropertySources : " + environment); + LOG.debug(() -> "sources from locateMapPropertySources : " + result); return result; } static boolean changed(List left, List right) { if (left.size() != right.size()) { - LOG.warn(() -> "The current number of ConfigMap PropertySources does not match " - + "the ones loaded from the Kubernetes - No reload will take place"); - if (LOG.isDebugEnabled()) { LOG.debug("left size: " + left.size()); left.forEach(item -> LOG.debug(item.toString())); @@ -141,14 +149,20 @@ static boolean changed(List left, List LOG.debug(item.toString())); } + LOG.warn(() -> "The current number of ConfigMap PropertySources does not match " + + "the ones loaded from Kubernetes - No reload will take place"); return false; } for (int i = 0; i < left.size(); i++) { - if (changed(left.get(i), right.get(i))) { + MapPropertySource leftPropertySource = left.get(i); + MapPropertySource rightPropertySource = right.get(i); + if (changed(leftPropertySource, rightPropertySource)) { + LOG.debug(() -> "found change in : " + leftPropertySource); return true; } } + LOG.debug(() -> "no changes found, reload will not happen"); return false; } 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 cc187cbd35..280d95f9d5 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.MountConfigMapPropertySource; import org.springframework.core.env.CompositePropertySource; import org.springframework.core.env.EnumerablePropertySource; import org.springframework.core.env.MapPropertySource; @@ -138,12 +139,15 @@ public Object getProperty(String name) { return null; } })); - - List result = ConfigReloadUtil.findPropertySources(PlainPropertySource.class, environment); - Assertions.assertEquals(3, result.size()); - Assertions.assertEquals("plain", result.get(0).getProperty("")); - Assertions.assertEquals("from-bootstrap", result.get(1).getProperty("")); - Assertions.assertEquals("from-inner-two-composite", result.get(2).getProperty("")); + propertySources.addFirst(new MountConfigMapPropertySource("mounted", Map.of("a", "b"))); + + List result = ConfigReloadUtil.findPropertySources(PlainPropertySource.class, + environment); + Assertions.assertEquals(4, result.size()); + Assertions.assertEquals("b", result.get(0).getProperty("a")); + Assertions.assertEquals("plain", result.get(1).getProperty("")); + Assertions.assertEquals("from-bootstrap", result.get(2).getProperty("")); + Assertions.assertEquals("from-inner-two-composite", result.get(3).getProperty("")); } private static final class OneComposite extends CompositePropertySource { diff --git a/spring-cloud-kubernetes-integration-tests/pom.xml b/spring-cloud-kubernetes-integration-tests/pom.xml index 9c14c34993..06f512dd62 100644 --- a/spring-cloud-kubernetes-integration-tests/pom.xml +++ b/spring-cloud-kubernetes-integration-tests/pom.xml @@ -70,5 +70,6 @@ spring-cloud-kubernetes-client-catalog-watcher spring-cloud-kubernetes-client-discovery-it spring-cloud-kubernetes-fabric8-client-discovery-with-bootstrap - + spring-cloud-kubernetes-client-configmap-polling-reload + diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/pom.xml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/pom.xml new file mode 100644 index 0000000000..4f97d75c6b --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/pom.xml @@ -0,0 +1,116 @@ + + + 4.0.0 + + org.springframework.cloud + spring-cloud-kubernetes-integration-tests + 3.0.3-SNAPSHOT + + + spring-cloud-kubernetes-client-configmap-polling-reload + + + + org.springframework.cloud + spring-cloud-starter-kubernetes-client-config + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.boot + spring-boot-starter-actuator + + + + + + + org.springframework.cloud + spring-cloud-starter + + + + org.springframework.cloud + spring-cloud-kubernetes-test-support + + + + + + + + ../src/main/resources + true + + + src/main/resources + true + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + docker.io/springcloud/${project.artifactId}:${project.version} + paketobuildpacks/builder + + + + build-image + + ${skip.build.image} + + package + + build-image + + + + repackage + package + + repackage + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + + + + + + ${testsToRun} + + + + + + + + diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/main/java/org/springframework/cloud/kubernetes/client/configmap/polling/reload/ConfigMapApp.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/main/java/org/springframework/cloud/kubernetes/client/configmap/polling/reload/ConfigMapApp.java new file mode 100644 index 0000000000..08f7065777 --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/main/java/org/springframework/cloud/kubernetes/client/configmap/polling/reload/ConfigMapApp.java @@ -0,0 +1,35 @@ +/* + * Copyright 2013-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.client.configmap.polling.reload; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +/** + * @author wind57 + */ + +@SpringBootApplication +@EnableConfigurationProperties(ConfigMapProperties.class) +public class ConfigMapApp { + + public static void main(String[] args) { + SpringApplication.run(ConfigMapApp.class, args); + } + +} diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/main/java/org/springframework/cloud/kubernetes/client/configmap/polling/reload/ConfigMapController.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/main/java/org/springframework/cloud/kubernetes/client/configmap/polling/reload/ConfigMapController.java new file mode 100644 index 0000000000..7d8cbedfde --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/main/java/org/springframework/cloud/kubernetes/client/configmap/polling/reload/ConfigMapController.java @@ -0,0 +1,39 @@ +/* + * Copyright 2013-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.client.configmap.polling.reload; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * @author wind57 + */ +@RestController +public class ConfigMapController { + + private final ConfigMapProperties properties; + + public ConfigMapController(ConfigMapProperties properties) { + this.properties = properties; + } + + @GetMapping("/key") + public String key() { + return properties.getKey(); + } + +} diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/main/java/org/springframework/cloud/kubernetes/client/configmap/polling/reload/ConfigMapProperties.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/main/java/org/springframework/cloud/kubernetes/client/configmap/polling/reload/ConfigMapProperties.java new file mode 100644 index 0000000000..c4c11b438b --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/main/java/org/springframework/cloud/kubernetes/client/configmap/polling/reload/ConfigMapProperties.java @@ -0,0 +1,37 @@ +/* + * Copyright 2013-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.client.configmap.polling.reload; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author wind57 + */ +@ConfigurationProperties("from.properties") +public class ConfigMapProperties { + + private String key; + + public String getKey() { + return key; + } + + public void setKey(String key1) { + this.key = key1; + } + +} diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/main/resources/application-mount.yaml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/main/resources/application-mount.yaml new file mode 100644 index 0000000000..cd1765f5c8 --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/main/resources/application-mount.yaml @@ -0,0 +1,17 @@ +spring: + application: + name: poll-reload-mount + cloud: + kubernetes: + reload: + enabled: true + monitoring-config-maps: true + strategy: shutdown + mode: polling + period: 5000 + config: + paths: + - /tmp/application.properties + config: + import: "kubernetes:" + diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/main/resources/application-with-bootstrap.yaml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/main/resources/application-with-bootstrap.yaml new file mode 100644 index 0000000000..c997322d16 --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/main/resources/application-with-bootstrap.yaml @@ -0,0 +1,12 @@ +spring: + application: + name: poll-reload-mount-boostrap + cloud: + kubernetes: + reload: + enabled: true + monitoring-config-maps: true + strategy: shutdown + mode: polling + period: 5000 + diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/main/resources/bootstrap-with-bootstrap.yaml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/main/resources/bootstrap-with-bootstrap.yaml new file mode 100644 index 0000000000..0aed2eb9dd --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/main/resources/bootstrap-with-bootstrap.yaml @@ -0,0 +1,6 @@ +spring: + cloud: + kubernetes: + config: + paths: + - /tmp/application.properties diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/java/org/springframework/cloud/kubernetes/client/configmap/polling/reload/BootstrapEnabledPollingReloadConfigMapMountIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/java/org/springframework/cloud/kubernetes/client/configmap/polling/reload/BootstrapEnabledPollingReloadConfigMapMountIT.java new file mode 100644 index 0000000000..dd2502de4a --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/java/org/springframework/cloud/kubernetes/client/configmap/polling/reload/BootstrapEnabledPollingReloadConfigMapMountIT.java @@ -0,0 +1,186 @@ +/* + * Copyright 2013-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.client.configmap.polling.reload; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1ConfigMap; +import io.kubernetes.client.openapi.models.V1Deployment; +import io.kubernetes.client.openapi.models.V1EnvVar; +import io.kubernetes.client.openapi.models.V1Ingress; +import io.kubernetes.client.openapi.models.V1Service; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.Container; +import org.testcontainers.k3s.K3sContainer; +import reactor.netty.http.client.HttpClient; +import reactor.util.retry.Retry; +import reactor.util.retry.RetryBackoffSpec; + +import org.springframework.cloud.kubernetes.integration.tests.commons.Commons; +import org.springframework.cloud.kubernetes.integration.tests.commons.Phase; +import org.springframework.cloud.kubernetes.integration.tests.commons.native_client.Util; +import org.springframework.http.HttpMethod; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; + +import static org.awaitility.Awaitility.await; + +/** + * @author wind57 + */ +class BootstrapEnabledPollingReloadConfigMapMountIT { + + private static final String IMAGE_NAME = "spring-cloud-kubernetes-client-configmap-polling-reload"; + + private static final String NAMESPACE = "default"; + + private static Util util; + + private static CoreV1Api coreV1Api; + + private static final K3sContainer K3S = Commons.container(); + + @BeforeAll + static void beforeAll() throws Exception { + K3S.start(); + Commons.validateImage(IMAGE_NAME, K3S); + Commons.loadSpringCloudKubernetesImage(IMAGE_NAME, K3S); + util = new Util(K3S); + coreV1Api = new CoreV1Api(); + util.setUp(NAMESPACE); + manifests(Phase.CREATE); + } + + @AfterAll + static void after() throws Exception { + manifests(Phase.DELETE); + Commons.cleanUp(IMAGE_NAME, K3S); + } + + /** + *
+	 *     - we have bootstrap enabled, which means we will 'locate' property sources
+	 *       from config maps.
+	 *     - there are no explicit config maps to search for, but what we will also read,
+	 *     	 is 'spring.cloud.kubernetes.config.paths', which we have set to
+	 *     	 '/tmp/application.properties'
+	 *       in this test. That is populated by the volumeMounts (see deployment-mount.yaml)
+	 *     - we first assert that we are actually reading the path based source via (1), (2) and (3).
+	 *
+	 *     - we then change the config map content, wait for k8s to pick it up and replace them
+	 *     - our polling will then detect that change, and trigger a reload.
+	 * 
+ */ + @Test + void test() throws Exception { + String logs = logs(); + // (1) + Assertions.assertTrue(logs.contains("paths property sources : [/tmp/application.properties]")); + // (2) + Assertions.assertTrue(logs.contains("will add file-based property source : /tmp/application.properties")); + // (3) + WebClient webClient = builder().baseUrl("http://localhost/key").build(); + String result = webClient.method(HttpMethod.GET).retrieve().bodyToMono(String.class).retryWhen(retrySpec()) + .block(); + + // we first read the initial value from the configmap + Assertions.assertEquals("as-mount-initial", result); + + // replace data in configmap and wait for k8s to pick it up + // our polling will detect that and restart the app + V1ConfigMap configMap = (V1ConfigMap) util.yaml("configmap-mount.yaml"); + configMap.setData(Map.of("application.properties", "from.properties.key=as-mount-changed")); + coreV1Api.replaceNamespacedConfigMap("poll-reload-as-mount", NAMESPACE, configMap, null, null, null, null); + + await().timeout(Duration.ofSeconds(180)).until(() -> webClient.method(HttpMethod.GET).retrieve() + .bodyToMono(String.class).retryWhen(retrySpec()).block().equals("as-mount-changed")); + + } + + private static void manifests(Phase phase) { + + V1Deployment deployment = (V1Deployment) util.yaml("deployment-mount.yaml"); + V1Service service = (V1Service) util.yaml("service.yaml"); + V1Ingress ingress = (V1Ingress) util.yaml("ingress.yaml"); + V1ConfigMap configMap = (V1ConfigMap) util.yaml("configmap-mount.yaml"); + + List existing = new ArrayList<>( + Optional.ofNullable(deployment.getSpec().getTemplate().getSpec().getContainers().get(0).getEnv()) + .orElse(new ArrayList<>())); + + // bootstrap is enabled, which means that in 'application-with-bootstrap.yaml', + // config-data support is disabled. + V1EnvVar mountActiveProfile = new V1EnvVar().name("SPRING_PROFILES_ACTIVE").value("with-bootstrap"); + V1EnvVar disableBootstrap = new V1EnvVar().name("SPRING_CLOUD_BOOTSTRAP_ENABLED").value("TRUE"); + + V1EnvVar debugLevelReloadCommons = new V1EnvVar() + .name("LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_KUBERNETES_COMMONS_CONFIG_RELOAD").value("DEBUG"); + V1EnvVar debugLevelConfig = new V1EnvVar() + .name("LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_KUBERNETES_COMMONS_CONFIG").value("DEBUG"); + V1EnvVar debugLevelCommons = new V1EnvVar().name("LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_KUBERNETES_COMMONS") + .value("DEBUG"); + + existing.add(mountActiveProfile); + existing.add(disableBootstrap); + existing.add(debugLevelReloadCommons); + existing.add(debugLevelCommons); + existing.add(debugLevelConfig); + deployment.getSpec().getTemplate().getSpec().getContainers().get(0).setEnv(existing); + + if (phase.equals(Phase.CREATE)) { + util.createAndWait(NAMESPACE, configMap, null); + util.createAndWait(NAMESPACE, null, deployment, service, ingress, true); + } + else { + util.deleteAndWait(NAMESPACE, configMap, null); + util.deleteAndWait(NAMESPACE, deployment, service, ingress); + } + + } + + private WebClient.Builder builder() { + return WebClient.builder().clientConnector(new ReactorClientHttpConnector(HttpClient.create())); + } + + private RetryBackoffSpec retrySpec() { + return Retry.fixedDelay(60, Duration.ofSeconds(1)).filter(Objects::nonNull); + } + + private String logs() { + try { + String appPodName = K3S.execInContainer("sh", "-c", + "kubectl get pods -l app=" + IMAGE_NAME + " -o=name --no-headers | tr -d '\n'").getStdout(); + + Container.ExecResult execResult = K3S.execInContainer("sh", "-c", "kubectl logs " + appPodName.trim()); + return execResult.getStdout(); + } + catch (Exception e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + +} diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/java/org/springframework/cloud/kubernetes/client/configmap/polling/reload/PollingReloadConfigMapMountIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/java/org/springframework/cloud/kubernetes/client/configmap/polling/reload/PollingReloadConfigMapMountIT.java new file mode 100644 index 0000000000..cb366fc398 --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/java/org/springframework/cloud/kubernetes/client/configmap/polling/reload/PollingReloadConfigMapMountIT.java @@ -0,0 +1,187 @@ +/* + * Copyright 2013-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.client.configmap.polling.reload; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1ConfigMap; +import io.kubernetes.client.openapi.models.V1Deployment; +import io.kubernetes.client.openapi.models.V1EnvVar; +import io.kubernetes.client.openapi.models.V1Ingress; +import io.kubernetes.client.openapi.models.V1Service; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.Container; +import org.testcontainers.k3s.K3sContainer; +import reactor.netty.http.client.HttpClient; +import reactor.util.retry.Retry; +import reactor.util.retry.RetryBackoffSpec; + +import org.springframework.cloud.kubernetes.integration.tests.commons.Commons; +import org.springframework.cloud.kubernetes.integration.tests.commons.Phase; +import org.springframework.cloud.kubernetes.integration.tests.commons.native_client.Util; +import org.springframework.http.HttpMethod; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; + +import static org.awaitility.Awaitility.await; + +/** + * @author wind57 + */ +class PollingReloadConfigMapMountIT { + + private static final String IMAGE_NAME = "spring-cloud-kubernetes-client-configmap-polling-reload"; + + private static final String NAMESPACE = "default"; + + private static Util util; + + private static CoreV1Api coreV1Api; + + private static final K3sContainer K3S = Commons.container(); + + @BeforeAll + static void beforeAll() throws Exception { + K3S.start(); + Commons.validateImage(IMAGE_NAME, K3S); + Commons.loadSpringCloudKubernetesImage(IMAGE_NAME, K3S); + util = new Util(K3S); + coreV1Api = new CoreV1Api(); + util.setUp(NAMESPACE); + manifests(Phase.CREATE); + } + + @AfterAll + static void after() throws Exception { + manifests(Phase.DELETE); + Commons.cleanUp(IMAGE_NAME, K3S); + } + + /** + *
+	 *     - we have "spring.config.import: kubernetes", which means we will 'locate' property sources
+	 *       from config maps.
+	 *     - the property above means that at the moment we will be searching for config maps that only
+	 *       match the application name, in this specific test there is no such config map.
+	 *     - what we will also read, is 'spring.cloud.kubernetes.config.paths', which we have set to
+	 *     	 '/tmp/application.properties'
+	 *       in this test. That is populated by the volumeMounts (see deployment-mount.yaml)
+	 *     - we first assert that we are actually reading the path based source via (1), (2) and (3).
+	 *
+	 *     - we then change the config map content, wait for k8s to pick it up and replace them
+	 *     - our polling will then detect that change, and trigger a reload.
+	 * 
+ */ + @Test + void test() throws Exception { + String logs = logs(); + // (1) + Assertions.assertTrue(logs.contains("paths property sources : [/tmp/application.properties]")); + // (2) + Assertions.assertTrue(logs.contains("will add file-based property source : /tmp/application.properties")); + // (3) + WebClient webClient = builder().baseUrl("http://localhost/key").build(); + String result = webClient.method(HttpMethod.GET).retrieve().bodyToMono(String.class).retryWhen(retrySpec()) + .block(); + + // we first read the initial value from the configmap + Assertions.assertEquals("as-mount-initial", result); + + // replace data in configmap and wait for k8s to pick it up + // our polling will detect that and restart the app + V1ConfigMap configMap = (V1ConfigMap) util.yaml("configmap-mount.yaml"); + configMap.setData(Map.of("application.properties", "from.properties.key=as-mount-changed")); + coreV1Api.replaceNamespacedConfigMap("poll-reload-as-mount", NAMESPACE, configMap, null, null, null, null); + + await().timeout(Duration.ofSeconds(180)).until(() -> webClient.method(HttpMethod.GET).retrieve() + .bodyToMono(String.class).retryWhen(retrySpec()).block().equals("as-mount-changed")); + + } + + private static void manifests(Phase phase) { + + V1Deployment deployment = (V1Deployment) util.yaml("deployment-mount.yaml"); + V1Service service = (V1Service) util.yaml("service.yaml"); + V1Ingress ingress = (V1Ingress) util.yaml("ingress.yaml"); + V1ConfigMap configMap = (V1ConfigMap) util.yaml("configmap-mount.yaml"); + + List existing = new ArrayList<>( + Optional.ofNullable(deployment.getSpec().getTemplate().getSpec().getContainers().get(0).getEnv()) + .orElse(new ArrayList<>())); + + // bootstrap is disabled, which means that in 'application-mount.yaml', + // config-data support is enabled. + V1EnvVar mountActiveProfile = new V1EnvVar().name("SPRING_PROFILES_ACTIVE").value("mount"); + V1EnvVar disableBootstrap = new V1EnvVar().name("SPRING_CLOUD_BOOTSTRAP_ENABLED").value("FALSE"); + + V1EnvVar debugLevelReloadCommons = new V1EnvVar() + .name("LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_KUBERNETES_COMMONS_CONFIG_RELOAD").value("DEBUG"); + V1EnvVar debugLevelConfig = new V1EnvVar() + .name("LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_KUBERNETES_COMMONS_CONFIG").value("DEBUG"); + V1EnvVar debugLevelCommons = new V1EnvVar().name("LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_KUBERNETES_COMMONS") + .value("DEBUG"); + + existing.add(mountActiveProfile); + existing.add(disableBootstrap); + existing.add(debugLevelReloadCommons); + existing.add(debugLevelCommons); + existing.add(debugLevelConfig); + deployment.getSpec().getTemplate().getSpec().getContainers().get(0).setEnv(existing); + + if (phase.equals(Phase.CREATE)) { + util.createAndWait(NAMESPACE, configMap, null); + util.createAndWait(NAMESPACE, null, deployment, service, ingress, true); + } + else { + util.deleteAndWait(NAMESPACE, configMap, null); + util.deleteAndWait(NAMESPACE, deployment, service, ingress); + } + + } + + private WebClient.Builder builder() { + return WebClient.builder().clientConnector(new ReactorClientHttpConnector(HttpClient.create())); + } + + private RetryBackoffSpec retrySpec() { + return Retry.fixedDelay(60, Duration.ofSeconds(1)).filter(Objects::nonNull); + } + + private String logs() { + try { + String appPodName = K3S.execInContainer("sh", "-c", + "kubectl get pods -l app=" + IMAGE_NAME + " -o=name --no-headers | tr -d '\n'").getStdout(); + + Container.ExecResult execResult = K3S.execInContainer("sh", "-c", "kubectl logs " + appPodName.trim()); + return execResult.getStdout(); + } + catch (Exception e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + +} diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/resources/configmap-mount.yaml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/resources/configmap-mount.yaml new file mode 100644 index 0000000000..f2ea29a5c2 --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/resources/configmap-mount.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: poll-reload-as-mount + namespace: default +data: + application.properties: | + from.properties.key=as-mount-initial diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/resources/deployment-mount.yaml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/resources/deployment-mount.yaml new file mode 100644 index 0000000000..1405330238 --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/resources/deployment-mount.yaml @@ -0,0 +1,37 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: spring-cloud-kubernetes-client-configmap-deployment-polling-reload +spec: + selector: + matchLabels: + app: spring-cloud-kubernetes-client-configmap-polling-reload + template: + metadata: + labels: + app: spring-cloud-kubernetes-client-configmap-polling-reload + spec: + serviceAccountName: spring-cloud-kubernetes-serviceaccount + containers: + - name: spring-cloud-kubernetes-client-configmap-polling-reload + image: docker.io/springcloud/spring-cloud-kubernetes-client-configmap-polling-reload + imagePullPolicy: IfNotPresent + readinessProbe: + httpGet: + port: 8080 + path: /actuator/health/readiness + livenessProbe: + httpGet: + port: 8080 + path: /actuator/health/liveness + ports: + - containerPort: 8080 + + volumeMounts: + - name: config-map-volume + mountPath: /tmp + + volumes: + - name: config-map-volume + configMap: + name: poll-reload-as-mount diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/resources/ingress.yaml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/resources/ingress.yaml new file mode 100644 index 0000000000..64f5bf4c02 --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/resources/ingress.yaml @@ -0,0 +1,16 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: spring-cloud-kubernetes-client-configmap-ingress-polling-reload + namespace: default +spec: + rules: + - http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: spring-cloud-kubernetes-client-configmap-polling-reload + port: + number: 8080 diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/resources/logback-test.xml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/resources/logback-test.xml new file mode 100644 index 0000000000..ee24334373 --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/resources/logback-test.xml @@ -0,0 +1,15 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n + + + + + + + + + + + diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/resources/service.yaml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/resources/service.yaml new file mode 100644 index 0000000000..19d5acda8d --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-client-configmap-polling-reload/src/test/resources/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: spring-cloud-kubernetes-client-configmap-polling-reload + name: spring-cloud-kubernetes-client-configmap-polling-reload +spec: + ports: + - name: http + port: 8080 + targetPort: 8080 + selector: + app: spring-cloud-kubernetes-client-configmap-polling-reload + type: ClusterIP diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/pom.xml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/pom.xml index 1e2fec848f..adad09d71a 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/pom.xml +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/pom.xml @@ -26,6 +26,14 @@ spring-boot-starter-actuator + + + + + org.springframework.cloud + spring-cloud-starter + + org.springframework.cloud spring-cloud-kubernetes-test-support diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/main/resources/application-mount.yaml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/main/resources/application-mount.yaml new file mode 100644 index 0000000000..cd1765f5c8 --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/main/resources/application-mount.yaml @@ -0,0 +1,17 @@ +spring: + application: + name: poll-reload-mount + cloud: + kubernetes: + reload: + enabled: true + monitoring-config-maps: true + strategy: shutdown + mode: polling + period: 5000 + config: + paths: + - /tmp/application.properties + config: + import: "kubernetes:" + diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/main/resources/application.yaml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/main/resources/application-no-mount.yaml similarity index 100% rename from spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/main/resources/application.yaml rename to spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/main/resources/application-no-mount.yaml diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/main/resources/application-with-bootstrap.yaml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/main/resources/application-with-bootstrap.yaml new file mode 100644 index 0000000000..c997322d16 --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/main/resources/application-with-bootstrap.yaml @@ -0,0 +1,12 @@ +spring: + application: + name: poll-reload-mount-boostrap + cloud: + kubernetes: + reload: + enabled: true + monitoring-config-maps: true + strategy: shutdown + mode: polling + period: 5000 + diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/main/resources/bootstrap-with-bootstrap.yaml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/main/resources/bootstrap-with-bootstrap.yaml new file mode 100644 index 0000000000..0aed2eb9dd --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/main/resources/bootstrap-with-bootstrap.yaml @@ -0,0 +1,6 @@ +spring: + cloud: + kubernetes: + config: + paths: + - /tmp/application.properties diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/test/java/org/springframework/cloud/kubernetes/fabric8/configmap/polling/reload/BootstrapEnabledPollingReloadConfigMapMountIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/test/java/org/springframework/cloud/kubernetes/fabric8/configmap/polling/reload/BootstrapEnabledPollingReloadConfigMapMountIT.java new file mode 100644 index 0000000000..c41b6beb80 --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/test/java/org/springframework/cloud/kubernetes/fabric8/configmap/polling/reload/BootstrapEnabledPollingReloadConfigMapMountIT.java @@ -0,0 +1,193 @@ +/* + * Copyright 2013-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.fabric8.configmap.polling.reload; + +import java.io.InputStream; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.EnvVar; +import io.fabric8.kubernetes.api.model.EnvVarBuilder; +import io.fabric8.kubernetes.api.model.Service; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.fabric8.kubernetes.client.KubernetesClient; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.Container; +import org.testcontainers.k3s.K3sContainer; +import reactor.netty.http.client.HttpClient; +import reactor.util.retry.Retry; +import reactor.util.retry.RetryBackoffSpec; + +import org.springframework.cloud.kubernetes.integration.tests.commons.Commons; +import org.springframework.cloud.kubernetes.integration.tests.commons.Phase; +import org.springframework.cloud.kubernetes.integration.tests.commons.fabric8_client.Util; +import org.springframework.http.HttpMethod; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; + +import static org.awaitility.Awaitility.await; + +public class BootstrapEnabledPollingReloadConfigMapMountIT { + + private static final String IMAGE_NAME = "spring-cloud-kubernetes-fabric8-client-configmap-polling-reload"; + + private static final String NAMESPACE = "default"; + + private static Util util; + + private static KubernetesClient client; + + private static final K3sContainer K3S = Commons.container(); + + @BeforeAll + static void beforeAll() throws Exception { + K3S.start(); + Commons.validateImage(IMAGE_NAME, K3S); + Commons.loadSpringCloudKubernetesImage(IMAGE_NAME, K3S); + util = new Util(K3S); + client = util.client(); + util.setUp(NAMESPACE); + manifests(Phase.CREATE); + } + + @AfterAll + static void after() throws Exception { + manifests(Phase.DELETE); + Commons.cleanUp(IMAGE_NAME, K3S); + } + + /** + *
+	 *     - we have bootstrap enabled, which means we will 'locate' property sources
+	 *       from config maps.
+	 *     - there are no explicit config maps to search for, but what we will also read,
+	 *     	 is 'spring.cloud.kubernetes.config.paths', which we have set to
+	 *     	 '/tmp/application.properties'
+	 *       in this test. That is populated by the volumeMounts (see deployment-mount.yaml)
+	 *     - we first assert that we are actually reading the path based source via (1), (2) and (3).
+	 *
+	 *     - we then change the config map content, wait for k8s to pick it up and replace them
+	 *     - our polling will then detect that change, and trigger a reload.
+	 * 
+ */ + @Test + void test() { + String logs = logs(); + // (1) + Assertions.assertTrue(logs.contains("paths property sources : [/tmp/application.properties]")); + // (2) + Assertions.assertTrue(logs.contains("will add file-based property source : /tmp/application.properties")); + // (3) + WebClient webClient = builder().baseUrl("http://localhost/key").build(); + String result = webClient.method(HttpMethod.GET).retrieve().bodyToMono(String.class).retryWhen(retrySpec()) + .block(); + + // we first read the initial value from the configmap + Assertions.assertEquals("as-mount-initial", result); + + // replace data in configmap and wait for k8s to pick it up + // our polling will detect that and restart the app + InputStream configMapStream = util.inputStream("mount/configmap-mount.yaml"); + ConfigMap configMap = client.configMaps().load(configMapStream).get(); + configMap.setData(Map.of("application.properties", "from.properties.key=as-mount-changed")); + client.configMaps().inNamespace("default").resource(configMap).createOrReplace(); + + await().timeout(Duration.ofSeconds(360)).until(() -> webClient.method(HttpMethod.GET).retrieve() + .bodyToMono(String.class).retryWhen(retrySpec()).block().equals("as-mount-changed")); + + } + + private static void manifests(Phase phase) { + + InputStream deploymentStream = util.inputStream("mount/deployment-mount.yaml"); + InputStream serviceStream = util.inputStream("service.yaml"); + InputStream ingressStream = util.inputStream("ingress.yaml"); + InputStream configMapStream = util.inputStream("mount/configmap-mount.yaml"); + + Deployment deployment = client.apps().deployments().load(deploymentStream).get(); + Service service = client.services().load(serviceStream).get(); + Ingress ingress = client.network().v1().ingresses().load(ingressStream).get(); + ConfigMap configMap = client.configMaps().load(configMapStream).get(); + + List existing = new ArrayList<>( + deployment.getSpec().getTemplate().getSpec().getContainers().get(0).getEnv()); + + // bootstrap is enabled, which means that in 'application-with-bootstrap.yaml', + // config-data support is disabled. + EnvVar withBootstrapActiveProfile = new EnvVarBuilder().withName("SPRING_PROFILES_ACTIVE") + .withValue("with-bootstrap").build(); + EnvVar enabledBootstrap = new EnvVarBuilder().withName("SPRING_CLOUD_BOOTSTRAP_ENABLED").withValue("TRUE") + .build(); + + EnvVar debugLevelReloadCommons = new EnvVarBuilder() + .withName("LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_KUBERNETES_COMMONS_CONFIG_RELOAD").withValue("DEBUG") + .build(); + EnvVar debugLevelConfig = new EnvVarBuilder() + .withName("LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_KUBERNETES_COMMONS_CONFIG").withValue("DEBUG") + .build(); + EnvVar debugLevelCommons = new EnvVarBuilder() + .withName("LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_KUBERNETES_COMMONS").withValue("DEBUG").build(); + + existing.add(withBootstrapActiveProfile); + existing.add(enabledBootstrap); + existing.add(debugLevelReloadCommons); + existing.add(debugLevelCommons); + existing.add(debugLevelConfig); + deployment.getSpec().getTemplate().getSpec().getContainers().get(0).setEnv(existing); + + if (phase.equals(Phase.CREATE)) { + util.createAndWait(NAMESPACE, configMap, null); + util.createAndWait(NAMESPACE, null, deployment, service, ingress, true); + } + else { + util.deleteAndWait(NAMESPACE, configMap, null); + util.deleteAndWait(NAMESPACE, deployment, service, ingress); + } + + } + + private WebClient.Builder builder() { + return WebClient.builder().clientConnector(new ReactorClientHttpConnector(HttpClient.create())); + } + + private RetryBackoffSpec retrySpec() { + return Retry.fixedDelay(60, Duration.ofSeconds(1)).filter(Objects::nonNull); + } + + private String logs() { + try { + String appPodName = K3S.execInContainer("sh", "-c", + "kubectl get pods -l app=" + IMAGE_NAME + " -o=name --no-headers | tr -d '\n'").getStdout(); + + Container.ExecResult execResult = K3S.execInContainer("sh", "-c", "kubectl logs " + appPodName.trim()); + return execResult.getStdout(); + } + catch (Exception e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + +} diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/test/java/org/springframework/cloud/kubernetes/fabric8/configmap/polling/reload/ConfigMapPollingReloadIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/test/java/org/springframework/cloud/kubernetes/fabric8/configmap/polling/reload/ConfigMapPollingReloadIT.java index 2f42269a66..54c74ab944 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/test/java/org/springframework/cloud/kubernetes/fabric8/configmap/polling/reload/ConfigMapPollingReloadIT.java +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/test/java/org/springframework/cloud/kubernetes/fabric8/configmap/polling/reload/ConfigMapPollingReloadIT.java @@ -18,11 +18,15 @@ import java.io.InputStream; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.Objects; import io.fabric8.kubernetes.api.model.ConfigMap; import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.EnvVar; +import io.fabric8.kubernetes.api.model.EnvVarBuilder; import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; import io.fabric8.kubernetes.api.model.Service; import io.fabric8.kubernetes.api.model.apps.Deployment; @@ -112,6 +116,11 @@ private static void manifests(Phase phase) { Ingress ingress = client.network().v1().ingresses().load(ingressStream).get(); ConfigMap configMap = client.configMaps().load(configMapStream).get(); + List existing = new ArrayList<>( + deployment.getSpec().getTemplate().getSpec().getContainers().get(0).getEnv()); + existing.add(new EnvVarBuilder().withName("SPRING_PROFILES_ACTIVE").withValue("no-mount").build()); + deployment.getSpec().getTemplate().getSpec().getContainers().get(0).setEnv(existing); + if (phase.equals(Phase.CREATE)) { util.createAndWait(NAMESPACE, configMap, null); util.createAndWait(NAMESPACE, null, deployment, service, ingress, true); diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/test/java/org/springframework/cloud/kubernetes/fabric8/configmap/polling/reload/PollingReloadConfigMapMountIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/test/java/org/springframework/cloud/kubernetes/fabric8/configmap/polling/reload/PollingReloadConfigMapMountIT.java new file mode 100644 index 0000000000..e05e48d6ae --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/test/java/org/springframework/cloud/kubernetes/fabric8/configmap/polling/reload/PollingReloadConfigMapMountIT.java @@ -0,0 +1,196 @@ +/* + * Copyright 2013-2023 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.kubernetes.fabric8.configmap.polling.reload; + +import java.io.InputStream; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.EnvVar; +import io.fabric8.kubernetes.api.model.EnvVarBuilder; +import io.fabric8.kubernetes.api.model.Service; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.fabric8.kubernetes.client.KubernetesClient; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.Container; +import org.testcontainers.k3s.K3sContainer; +import reactor.netty.http.client.HttpClient; +import reactor.util.retry.Retry; +import reactor.util.retry.RetryBackoffSpec; + +import org.springframework.cloud.kubernetes.integration.tests.commons.Commons; +import org.springframework.cloud.kubernetes.integration.tests.commons.Phase; +import org.springframework.cloud.kubernetes.integration.tests.commons.fabric8_client.Util; +import org.springframework.http.HttpMethod; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; + +import static org.awaitility.Awaitility.await; + +/** + * @author wind57 + */ +class PollingReloadConfigMapMountIT { + + private static final String IMAGE_NAME = "spring-cloud-kubernetes-fabric8-client-configmap-polling-reload"; + + private static final String NAMESPACE = "default"; + + private static Util util; + + private static KubernetesClient client; + + private static final K3sContainer K3S = Commons.container(); + + @BeforeAll + static void beforeAll() throws Exception { + K3S.start(); + Commons.validateImage(IMAGE_NAME, K3S); + Commons.loadSpringCloudKubernetesImage(IMAGE_NAME, K3S); + util = new Util(K3S); + client = util.client(); + util.setUp(NAMESPACE); + manifests(Phase.CREATE); + } + + @AfterAll + static void after() throws Exception { + manifests(Phase.DELETE); + Commons.cleanUp(IMAGE_NAME, K3S); + } + + /** + *
+	 *     - we have "spring.config.import: kubernetes", which means we will 'locate' property sources
+	 *       from config maps.
+	 *     - the property above means that at the moment we will be searching for config maps that only
+	 *       match the application name, in this specific test there is no such config map.
+	 *     - what we will also read, is 'spring.cloud.kubernetes.config.paths', which we have set to
+	 *     	 '/tmp/application.properties'
+	 *       in this test. That is populated by the volumeMounts (see deployment-mount.yaml)
+	 *     - we first assert that we are actually reading the path based source via (1), (2) and (3).
+	 *
+	 *     - we then change the config map content, wait for k8s to pick it up and replace them
+	 *     - our polling will then detect that change, and trigger a reload.
+	 * 
+ */ + @Test + void test() { + String logs = logs(); + // (1) + Assertions.assertTrue(logs.contains("paths property sources : [/tmp/application.properties]")); + // (2) + Assertions.assertTrue(logs.contains("will add file-based property source : /tmp/application.properties")); + // (3) + WebClient webClient = builder().baseUrl("http://localhost/key").build(); + String result = webClient.method(HttpMethod.GET).retrieve().bodyToMono(String.class).retryWhen(retrySpec()) + .block(); + + // we first read the initial value from the configmap + Assertions.assertEquals("as-mount-initial", result); + + // replace data in configmap and wait for k8s to pick it up + // our polling will detect that and restart the app + InputStream configMapStream = util.inputStream("mount/configmap-mount.yaml"); + ConfigMap configMap = client.configMaps().load(configMapStream).get(); + configMap.setData(Map.of("application.properties", "from.properties.key=as-mount-changed")); + client.configMaps().inNamespace("default").resource(configMap).createOrReplace(); + + await().timeout(Duration.ofSeconds(360)).until(() -> webClient.method(HttpMethod.GET).retrieve() + .bodyToMono(String.class).retryWhen(retrySpec()).block().equals("as-mount-changed")); + + } + + private static void manifests(Phase phase) { + + InputStream deploymentStream = util.inputStream("mount/deployment-mount.yaml"); + InputStream serviceStream = util.inputStream("service.yaml"); + InputStream ingressStream = util.inputStream("ingress.yaml"); + InputStream configMapStream = util.inputStream("mount/configmap-mount.yaml"); + + Deployment deployment = client.apps().deployments().load(deploymentStream).get(); + Service service = client.services().load(serviceStream).get(); + Ingress ingress = client.network().v1().ingresses().load(ingressStream).get(); + ConfigMap configMap = client.configMaps().load(configMapStream).get(); + + List existing = new ArrayList<>( + deployment.getSpec().getTemplate().getSpec().getContainers().get(0).getEnv()); + + // bootstrap is disabled, which means that in 'application-mount.yaml', + // config-data support is enabled. + EnvVar mountActiveProfile = new EnvVarBuilder().withName("SPRING_PROFILES_ACTIVE").withValue("mount").build(); + EnvVar disableBootstrap = new EnvVarBuilder().withName("SPRING_CLOUD_BOOTSTRAP_ENABLED").withValue("FALSE") + .build(); + + EnvVar debugLevelReloadCommons = new EnvVarBuilder() + .withName("LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_KUBERNETES_COMMONS_CONFIG_RELOAD").withValue("DEBUG") + .build(); + EnvVar debugLevelConfig = new EnvVarBuilder() + .withName("LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_KUBERNETES_COMMONS_CONFIG").withValue("DEBUG") + .build(); + EnvVar debugLevelCommons = new EnvVarBuilder() + .withName("LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_KUBERNETES_COMMONS").withValue("DEBUG").build(); + + existing.add(mountActiveProfile); + existing.add(disableBootstrap); + existing.add(debugLevelReloadCommons); + existing.add(debugLevelCommons); + existing.add(debugLevelConfig); + deployment.getSpec().getTemplate().getSpec().getContainers().get(0).setEnv(existing); + + if (phase.equals(Phase.CREATE)) { + util.createAndWait(NAMESPACE, configMap, null); + util.createAndWait(NAMESPACE, null, deployment, service, ingress, true); + } + else { + util.deleteAndWait(NAMESPACE, configMap, null); + util.deleteAndWait(NAMESPACE, deployment, service, ingress); + } + + } + + private WebClient.Builder builder() { + return WebClient.builder().clientConnector(new ReactorClientHttpConnector(HttpClient.create())); + } + + private RetryBackoffSpec retrySpec() { + return Retry.fixedDelay(60, Duration.ofSeconds(1)).filter(Objects::nonNull); + } + + private String logs() { + try { + String appPodName = K3S.execInContainer("sh", "-c", + "kubectl get pods -l app=" + IMAGE_NAME + " -o=name --no-headers | tr -d '\n'").getStdout(); + + Container.ExecResult execResult = K3S.execInContainer("sh", "-c", "kubectl logs " + appPodName.trim()); + return execResult.getStdout(); + } + catch (Exception e) { + e.printStackTrace(); + throw new RuntimeException(e); + } + } + +} diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/test/resources/mount/configmap-mount.yaml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/test/resources/mount/configmap-mount.yaml new file mode 100644 index 0000000000..f2ea29a5c2 --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/test/resources/mount/configmap-mount.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: poll-reload-as-mount + namespace: default +data: + application.properties: | + from.properties.key=as-mount-initial diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/test/resources/mount/deployment-mount.yaml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/test/resources/mount/deployment-mount.yaml new file mode 100644 index 0000000000..4678398618 --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload/src/test/resources/mount/deployment-mount.yaml @@ -0,0 +1,37 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: spring-cloud-kubernetes-fabric8-client-configmap-deployment-polling-reload +spec: + selector: + matchLabels: + app: spring-cloud-kubernetes-fabric8-client-configmap-polling-reload + template: + metadata: + labels: + app: spring-cloud-kubernetes-fabric8-client-configmap-polling-reload + spec: + serviceAccountName: spring-cloud-kubernetes-serviceaccount + containers: + - name: spring-cloud-kubernetes-fabric8-client-configmap-polling-reload + image: docker.io/springcloud/spring-cloud-kubernetes-fabric8-client-configmap-polling-reload + imagePullPolicy: IfNotPresent + readinessProbe: + httpGet: + port: 8080 + path: /actuator/health/readiness + livenessProbe: + httpGet: + port: 8080 + path: /actuator/health/liveness + ports: + - containerPort: 8080 + + volumeMounts: + - name: config-map-volume + mountPath: /tmp + + volumes: + - name: config-map-volume + configMap: + name: poll-reload-as-mount