diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/main/resources/application-three.yaml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/main/resources/application-three.yaml index fc367fcf..72f6a44c 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/main/resources/application-three.yaml +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/main/resources/application-three.yaml @@ -1,7 +1,3 @@ -logging: - level: - root: DEBUG - spring: application: name: event-reload @@ -9,7 +5,7 @@ spring: kubernetes: reload: enabled: true - strategy: shutdown + strategy: refresh mode: event namespaces: - right diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/main/resources/application-two.yaml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/main/resources/application-two.yaml index 43ae2737..f8071e88 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/main/resources/application-two.yaml +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/main/resources/application-two.yaml @@ -1,7 +1,3 @@ -logging: - level: - root: DEBUG - spring: application: name: event-reload @@ -9,7 +5,7 @@ spring: kubernetes: reload: enabled: true - strategy: shutdown + strategy: refresh mode: event namespaces: - right diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/main/resources/bootstrap-three.yaml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/main/resources/bootstrap-three.yaml index 5c5f3605..af51a86e 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/main/resources/bootstrap-three.yaml +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/main/resources/bootstrap-three.yaml @@ -1,15 +1,14 @@ -logging: - level: - root: DEBUG - spring: cloud: kubernetes: config: sources: - - namespace: left - name: left-configmap - namespace: right name: right-configmap - namespace: right name: right-configmap-with-label + + # otherwise on context refresh we lose this property + # and test fails, since beans are not wired. + main: + cloud-platform: kubernetes diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/main/resources/bootstrap-two.yaml b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/main/resources/bootstrap-two.yaml index 9265fce1..4c5a11e3 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/main/resources/bootstrap-two.yaml +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/main/resources/bootstrap-two.yaml @@ -1,13 +1,12 @@ -logging: - level: - root: DEBUG - spring: cloud: kubernetes: config: sources: - - namespace: left - name: left-configmap - namespace: right name: right-configmap + + # otherwise on context refresh we lose this property + # and test fails, since beans are not wired. + main: + cloud-platform: kubernetes diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/test/java/org/springframework/cloud/kubernetes/k8s/client/reload/configmap/K8sClientConfigMapReloadIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/test/java/org/springframework/cloud/kubernetes/k8s/client/reload/configmap/K8sClientConfigMapReloadIT.java index 9814a906..be593d37 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/test/java/org/springframework/cloud/kubernetes/k8s/client/reload/configmap/K8sClientConfigMapReloadIT.java +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/test/java/org/springframework/cloud/kubernetes/k8s/client/reload/configmap/K8sClientConfigMapReloadIT.java @@ -19,8 +19,6 @@ package org.springframework.cloud.kubernetes.k8s.client.reload.configmap; import java.time.Duration; import java.util.Map; import java.util.Set; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.LockSupport; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.apis.CoreV1Api; @@ -33,7 +31,6 @@ 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.k3s.K3sContainer; import org.springframework.cloud.kubernetes.integration.tests.commons.Commons; @@ -46,9 +43,7 @@ import static org.awaitility.Awaitility.await; import static org.springframework.cloud.kubernetes.k8s.client.reload.configmap.BootstrapEnabledPollingReloadConfigMapMountDelegate.testBootstrapEnabledPollingReloadConfigMapMount; import static org.springframework.cloud.kubernetes.k8s.client.reload.configmap.DataChangesInConfigMapReloadDelegate.testSimple; import static org.springframework.cloud.kubernetes.k8s.client.reload.configmap.K8sClientConfigMapReloadITUtil.builder; -import static org.springframework.cloud.kubernetes.k8s.client.reload.configmap.K8sClientConfigMapReloadITUtil.patchOne; import static org.springframework.cloud.kubernetes.k8s.client.reload.configmap.K8sClientConfigMapReloadITUtil.patchThree; -import static org.springframework.cloud.kubernetes.k8s.client.reload.configmap.K8sClientConfigMapReloadITUtil.patchTwo; import static org.springframework.cloud.kubernetes.k8s.client.reload.configmap.K8sClientConfigMapReloadITUtil.retrySpec; import static org.springframework.cloud.kubernetes.k8s.client.reload.configmap.PollingReloadConfigMapMountDelegate.testPollingReloadConfigMapMount; @@ -92,259 +87,15 @@ class K8sClientConfigMapReloadIT { util.deleteNamespace("right"); } - /** - *
- * - there are two namespaces : left and right - * - each of the namespaces has one configmap - * - we watch the "left" namespace, but make a change in the configmap in the right namespace - * - as such, no event is triggered and "left-configmap" stays as-is - *- */ - @Test - void testInformFromOneNamespaceEventNotTriggered() throws Exception { - manifests(Phase.CREATE); - Commons.assertReloadLogStatements("added configmap informer for namespace", - "added secret informer for namespace", "spring-k8s-client-reload"); - - WebClient webClient = builder().baseUrl("http://localhost/left").build(); - String result = webClient.method(HttpMethod.GET) - .retrieve() - .bodyToMono(String.class) - .retryWhen(retrySpec()) - .block(); - - // we first read the initial value from the left-configmap - Assertions.assertEquals("left-initial", result); - - // then read the value from the right-configmap - webClient = builder().baseUrl("http://localhost/right").build(); - result = webClient.method(HttpMethod.GET).retrieve().bodyToMono(String.class).retryWhen(retrySpec()).block(); - Assertions.assertEquals("right-initial", result); - - // then deploy a new version of right-configmap - V1ConfigMap rightConfigMapAfterChange = new V1ConfigMapBuilder() - .withMetadata(new V1ObjectMeta().namespace("right").name("right-configmap")) - .withData(Map.of("right.value", "right-after-change")) - .build(); - - replaceConfigMap(rightConfigMapAfterChange, "right-configmap"); - - // wait dummy for 5 seconds - LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(5)); - - webClient = builder().baseUrl("http://localhost/left").build(); - result = webClient.method(HttpMethod.GET).retrieve().bodyToMono(String.class).retryWhen(retrySpec()).block(); - // left configmap has not changed, no restart of app has happened - Assertions.assertEquals("left-initial", result); - - testAllOther(); - - } - // since we patch each deployment with "replace" strategy, any of the above can be // commented out and debugged individually. private void testAllOther() throws Exception { - testInformFromOneNamespaceEventTriggered(); - testInform(); - testInformFromOneNamespaceEventTriggeredSecretsDisabled(); testSimple(DOCKER_IMAGE, DEPLOYMENT_NAME, K3S); testPollingReloadConfigMapMount(DEPLOYMENT_NAME, K3S, util, DOCKER_IMAGE); testBootstrapEnabledPollingReloadConfigMapMount(DEPLOYMENT_NAME, K3S, util, DOCKER_IMAGE); } - /** - *
- * - there are two namespaces : left and right - * - each of the namespaces has one configmap - * - we watch the "right" namespace and make a change in the configmap in the same namespace - * - as such, event is triggered and we see the updated value - *- */ - void testInformFromOneNamespaceEventTriggered() throws Exception { - recreateConfigMaps(); - patchOne(DEPLOYMENT_NAME, NAMESPACE, DOCKER_IMAGE); - Commons.assertReloadLogStatements("added configmap informer for namespace", - "added secret informer for namespace", DEPLOYMENT_NAME); - - // read the value from the right-configmap - WebClient webClient = builder().baseUrl("http://localhost/right").build(); - String result = webClient.method(HttpMethod.GET) - .retrieve() - .bodyToMono(String.class) - .retryWhen(retrySpec()) - .block(); - Assertions.assertEquals("right-initial", result); - - // then deploy a new version of right-configmap - V1ConfigMap rightConfigMapAfterChange = new V1ConfigMapBuilder() - .withMetadata(new V1ObjectMeta().namespace("right").name("right-configmap")) - .withData(Map.of("right.value", "right-after-change")) - .build(); - - replaceConfigMap(rightConfigMapAfterChange, "right-configmap"); - - String[] resultAfterChange = new String[1]; - await().pollInterval(Duration.ofSeconds(3)).atMost(Duration.ofSeconds(90)).until(() -> { - WebClient innerWebClient = builder().baseUrl("http://localhost/right").build(); - String innerResult = innerWebClient.method(HttpMethod.GET) - .retrieve() - .bodyToMono(String.class) - .retryWhen(retrySpec()) - .block(); - - resultAfterChange[0] = innerResult; - return innerResult != null; - }); - Assertions.assertEquals("right-after-change", resultAfterChange[0]); - } - - /** - *
- * - there are two namespaces : left and right (though we do not care about the left one) - * - left has one configmap : left-configmap - * - right has two configmaps: right-configmap, right-configmap-with-label - * - we watch the "right" namespace, but enable tagging; which means that only - * right-configmap-with-label triggers changes. - *- */ - void testInform() throws Exception { - recreateConfigMaps(); - V1ConfigMap rightWithLabelConfigMap = (V1ConfigMap) util.yaml("right-configmap-with-label.yaml"); - util.createAndWait("right", rightWithLabelConfigMap, null); - patchTwo(DEPLOYMENT_NAME, NAMESPACE, DOCKER_IMAGE); - - Commons.assertReloadLogStatements("added configmap informer for namespace", - "added secret informer for namespace", DEPLOYMENT_NAME); - - // read the initial value from the right-configmap - WebClient rightWebClient = builder().baseUrl("http://localhost/right").build(); - String rightResult = rightWebClient.method(HttpMethod.GET) - .retrieve() - .bodyToMono(String.class) - .retryWhen(retrySpec()) - .block(); - Assertions.assertEquals("right-initial", rightResult); - - // then read the initial value from the right-with-label-configmap - WebClient rightWithLabelWebClient = builder().baseUrl("http://localhost/with-label").build(); - String rightWithLabelResult = rightWithLabelWebClient.method(HttpMethod.GET) - .retrieve() - .bodyToMono(String.class) - .retryWhen(retrySpec()) - .block(); - Assertions.assertEquals("right-with-label-initial", rightWithLabelResult); - - // then deploy a new version of right-configmap - V1ConfigMap rightConfigMapAfterChange = new V1ConfigMapBuilder() - .withMetadata(new V1ObjectMeta().namespace("right").name("right-configmap")) - .withData(Map.of("right.value", "right-after-change")) - .build(); - - replaceConfigMap(rightConfigMapAfterChange, "right-configmap"); - - // sleep for 5 seconds - LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(5)); - - // nothing changes in our app, because we are watching only labeled configmaps - rightResult = rightWebClient.method(HttpMethod.GET) - .retrieve() - .bodyToMono(String.class) - .retryWhen(retrySpec()) - .block(); - Assertions.assertEquals("right-initial", rightResult); - - // then deploy a new version of right-with-label-configmap - V1ConfigMap rightWithLabelConfigMapAfterChange = new V1ConfigMapBuilder() - .withMetadata(new V1ObjectMeta().namespace("right").name("right-configmap-with-label")) - .withData(Map.of("right.with.label.value", "right-with-label-after-change")) - .build(); - - replaceConfigMap(rightWithLabelConfigMapAfterChange, "right-configmap-with-label"); - - // since we have changed a labeled configmap, app will restart and pick up the new - // value - String[] resultAfterChange = new String[1]; - await().pollInterval(Duration.ofSeconds(3)).atMost(Duration.ofSeconds(90)).until(() -> { - WebClient innerWebClient = builder().baseUrl("http://localhost/with-label").build(); - String innerResult = innerWebClient.method(HttpMethod.GET) - .retrieve() - .bodyToMono(String.class) - .retryWhen(retrySpec()) - .block(); - resultAfterChange[0] = innerResult; - return innerResult != null; - }); - Assertions.assertEquals("right-with-label-after-change", resultAfterChange[0]); - - // right-configmap now will see the new value also, but only because the other - // configmap has triggered the restart - rightResult = rightWebClient.method(HttpMethod.GET) - .retrieve() - .bodyToMono(String.class) - .retryWhen(retrySpec()) - .block(); - Assertions.assertEquals("right-after-change", rightResult); - util.deleteAndWait("right", rightWithLabelConfigMap, null); - } - - /** - *
- * - there are two namespaces : left and right - * - each of the namespaces has one configmap - * - we watch the "right" namespace and make a change in the configmap in the same namespace - * - as such, event is triggered and we see the updated value - *- */ - void testInformFromOneNamespaceEventTriggeredSecretsDisabled() throws Exception { - recreateConfigMaps(); - patchThree(DEPLOYMENT_NAME, NAMESPACE, DOCKER_IMAGE); - Commons.assertReloadLogStatements("added configmap informer for namespace", - "added secret informer for namespace", DEPLOYMENT_NAME); - - // read the value from the right-configmap - WebClient webClient = builder().baseUrl("http://localhost/right").build(); - String result = webClient.method(HttpMethod.GET) - .retrieve() - .bodyToMono(String.class) - .retryWhen(retrySpec()) - .block(); - Assertions.assertEquals("right-initial", result); - - // then deploy a new version of right-configmap - V1ConfigMap rightConfigMapAfterChange = new V1ConfigMapBuilder() - .withMetadata(new V1ObjectMeta().namespace("right").name("right-configmap")) - .withData(Map.of("right.value", "right-after-change")) - .build(); - - replaceConfigMap(rightConfigMapAfterChange, "right-configmap"); - - String[] resultAfterChange = new String[1]; - await().pollInterval(Duration.ofSeconds(3)).atMost(Duration.ofSeconds(90)).until(() -> { - WebClient innerWebClient = builder().baseUrl("http://localhost/right").build(); - String innerResult = innerWebClient.method(HttpMethod.GET) - .retrieve() - .bodyToMono(String.class) - .retryWhen(retrySpec()) - .block(); - - resultAfterChange[0] = innerResult; - return innerResult != null; - }); - Assertions.assertEquals("right-after-change", resultAfterChange[0]); - } - - private void recreateConfigMaps() { - V1ConfigMap leftConfigMap = (V1ConfigMap) util.yaml("left-configmap.yaml"); - V1ConfigMap rightConfigMap = (V1ConfigMap) util.yaml("right-configmap.yaml"); - - util.deleteAndWait("left", leftConfigMap, null); - util.deleteAndWait("right", rightConfigMap, null); - - util.createAndWait("left", leftConfigMap, null); - util.createAndWait("right", rightConfigMap, null); - } - private static void manifests(Phase phase) { try { diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/test/java/org/springframework/cloud/kubernetes/k8s/client/reload/configmap/K8sClientConfigMapReloadITUtil.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/test/java/org/springframework/cloud/kubernetes/k8s/client/reload/configmap/K8sClientConfigMapReloadITUtil.java index 2043b40e..f7e7197d 100644 --- a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/test/java/org/springframework/cloud/kubernetes/k8s/client/reload/configmap/K8sClientConfigMapReloadITUtil.java +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/test/java/org/springframework/cloud/kubernetes/k8s/client/reload/configmap/K8sClientConfigMapReloadITUtil.java @@ -401,14 +401,6 @@ final class K8sClientConfigMapReloadITUtil { } """; - static void patchOne(String deploymentName, String namespace, String imageName) { - patchWithReplace(imageName, deploymentName, namespace, BODY_ONE, POD_LABELS); - } - - static void patchTwo(String deploymentName, String namespace, String imageName) { - patchWithReplace(imageName, deploymentName, namespace, BODY_TWO, POD_LABELS); - } - static void patchThree(String deploymentName, String namespace, String imageName) { patchWithReplace(imageName, deploymentName, namespace, BODY_THREE, POD_LABELS); } diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/test/java/org/springframework/cloud/kubernetes/k8s/client/reload/it/configmap/K8sClientConfigMapEventTriggeredIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/test/java/org/springframework/cloud/kubernetes/k8s/client/reload/it/configmap/K8sClientConfigMapEventTriggeredIT.java new file mode 100644 index 00000000..55a420ff --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/test/java/org/springframework/cloud/kubernetes/k8s/client/reload/it/configmap/K8sClientConfigMapEventTriggeredIT.java @@ -0,0 +1,136 @@ +/* + * Copyright 2013-2025 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.k8s.client.reload.it.configmap; + +import java.time.Duration; +import java.util.Map; + +import io.kubernetes.client.openapi.ApiClient; +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.V1ObjectMeta; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +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.cloud.kubernetes.client.KubernetesClientUtils; +import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider; +import org.springframework.cloud.kubernetes.k8s.client.reload.App; +import org.springframework.cloud.kubernetes.k8s.client.reload.RightProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.TestPropertySource; + +import static org.awaitility.Awaitility.await; + +/** + * @author wind57 + */ +@SpringBootTest(classes = { App.class, K8sClientConfigMapEventTriggeredIT.TestConfig.class }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestPropertySource(properties = { "spring.main.cloud-platform=kubernetes", "spring.profiles.active=two", + "spring.cloud.bootstrap.enabled=true", + "logging.level.org.springframework.cloud.kubernetes.client.config.reload=debug" }) +class K8sClientConfigMapEventTriggeredIT extends K8sClientReloadBase { + + private static final MockedStatic
+ * - there is one namespace : right + * - namespaces has one configmap + * - we watch this namespace and make a change in the configmap + * - as such, event is triggered and we see the updated value + *+ */ + @Test + void test(CapturedOutput output) { + + assertReloadLogStatements("added configmap informer for namespace : right with filter : null", + "added secret informer for namespace", output); + + Assertions.assertThat(rightProperties.getValue()).isEqualTo("right-initial"); + + // then deploy a new version of right-configmap + V1ConfigMap rightConfigMapAfterChange = new V1ConfigMapBuilder() + .withMetadata(new V1ObjectMeta().namespace(NAMESPACE_RIGHT).name("right-configmap")) + .withData(Map.of("right.value", "right-after-change")) + .build(); + + replaceConfigMap(coreV1Api, rightConfigMapAfterChange); + + await().atMost(Duration.ofSeconds(60)) + .pollDelay(Duration.ofSeconds(1)) + .until(() -> output.getOut().contains("ConfigMap right-configmap was updated in namespace right")); + + await().atMost(Duration.ofSeconds(60)) + .pollInterval(Duration.ofSeconds(1)) + .until(() -> rightProperties.getValue().equals("right-after-change")); + } + + @TestConfiguration + static class TestConfig { + + @Bean + @Primary + ApiClient client() { + return apiClient(); + } + + } + +} diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/test/java/org/springframework/cloud/kubernetes/k8s/client/reload/it/configmap/K8sClientConfigMapLabelEventTriggeredIT.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/test/java/org/springframework/cloud/kubernetes/k8s/client/reload/it/configmap/K8sClientConfigMapLabelEventTriggeredIT.java new file mode 100644 index 00000000..f16eda4f --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/test/java/org/springframework/cloud/kubernetes/k8s/client/reload/it/configmap/K8sClientConfigMapLabelEventTriggeredIT.java @@ -0,0 +1,185 @@ +/* + * Copyright 2013-2025 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.k8s.client.reload.it.configmap; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; + +import io.kubernetes.client.openapi.ApiClient; +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.V1ObjectMeta; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +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.cloud.kubernetes.client.KubernetesClientUtils; +import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider; +import org.springframework.cloud.kubernetes.k8s.client.reload.App; +import org.springframework.cloud.kubernetes.k8s.client.reload.RightProperties; +import org.springframework.cloud.kubernetes.k8s.client.reload.RightWithLabelsProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.TestPropertySource; + +import static org.awaitility.Awaitility.await; + +/** + * @author wind57 + */ +@SpringBootTest(classes = { App.class, K8sClientConfigMapLabelEventTriggeredIT.TestConfig.class }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestPropertySource(properties = { "spring.main.cloud-platform=kubernetes", "spring.profiles.active=three", + "spring.cloud.bootstrap.enabled=true", + "logging.level.org.springframework.cloud.kubernetes.client.config.reload=debug" }) +class K8sClientConfigMapLabelEventTriggeredIT extends K8sClientReloadBase { + + private static final MockedStatic
+ * - we have one namespace : 'right'. + * - it has two configmaps : 'right-configmap' and 'right-configmap-with-label' + * - we watch 'right' namespace, but enable tagging; which means that only + * right-configmap-with-label triggers a change. + *+ */ + @Test + void test(CapturedOutput output) { + + assertReloadLogStatements( + "added configmap informer for namespace : " + + "right with filter : spring.cloud.kubernetes.config.informer.enabled=true", + "added secret informer for namespace", output); + + // read the initial value from the right-configmap + Assertions.assertThat(rightProperties.getValue()).isEqualTo("right-initial"); + + // read the initial value from the right-configmap-with-label + Assertions.assertThat(rightWithLabelsProperties.getValue()).isEqualTo("right-with-label-initial"); + + // then deploy a new version of right-configmap + V1ConfigMap rightConfigMapAfterChange = new V1ConfigMapBuilder() + .withMetadata(new V1ObjectMeta().namespace(NAMESPACE_RIGHT).name("right-configmap")) + .withData(Map.of("right.value", "right-after-change")) + .build(); + + replaceConfigMap(coreV1Api, rightConfigMapAfterChange); + + // sleep for 5 seconds + LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(5)); + Assertions.assertThat(rightProperties.getValue()).isEqualTo("right-initial"); + + // then deploy a new version of right-configmap-with-label + // but only add a label, this does not trigger a refresh + V1ConfigMap rightWithLabelConfigMap = new V1ConfigMapBuilder() + .withMetadata(new V1ObjectMeta().namespace(NAMESPACE_RIGHT).name("right-configmap-with-label") + .labels(Map.of("spring.cloud.kubernetes.config.informer.enabled", "true", + "custom.label", "spring-k8s"))) + .build(); + + await().atMost(Duration.ofSeconds(60)) + .pollDelay(Duration.ofSeconds(1)) + .until(() -> output.getOut().contains("data in configmap has not changed, will not reload")); + + await().atMost(Duration.ofSeconds(60)) + .pollInterval(Duration.ofSeconds(1)) + .until(() -> rightWithLabelsProperties.getValue().equals("right-with-label-after-change")); + + // then deploy a new version of right-configmap-with-label + // that changes data also + V1ConfigMap rightWithLabelConfigMapAfterChange = new V1ConfigMapBuilder() + .withMetadata(new V1ObjectMeta().namespace(NAMESPACE_RIGHT).name("right-configmap-with-label") + .labels(Map.of("spring.cloud.kubernetes.config.informer.enabled", "true"))) + .withData(Map.of("right.with.label.value", "right-with-label-after-change")) + .build(); + + replaceConfigMap(coreV1Api, rightWithLabelConfigMapAfterChange); + + await().atMost(Duration.ofSeconds(60)) + .pollDelay(Duration.ofSeconds(1)) + .until(() -> output.getOut().contains("ConfigMap right-configmap-with-label was updated in namespace right")); + + await().atMost(Duration.ofSeconds(60)) + .pollInterval(Duration.ofSeconds(1)) + .until(() -> rightWithLabelsProperties.getValue().equals("right-with-label-after-change")); + } + + @TestConfiguration + static class TestConfig { + + @Bean + @Primary + ApiClient client() { + return apiClient(); + } + + } + +} diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/test/java/org/springframework/cloud/kubernetes/k8s/client/reload/it/configmap/K8sClientReloadBase.java b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/test/java/org/springframework/cloud/kubernetes/k8s/client/reload/it/configmap/K8sClientReloadBase.java new file mode 100644 index 00000000..cc7b078c --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/test/java/org/springframework/cloud/kubernetes/k8s/client/reload/it/configmap/K8sClientReloadBase.java @@ -0,0 +1,115 @@ +/* + * Copyright 2013-2025 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.k8s.client.reload.it.configmap; + +import java.io.IOException; +import java.io.StringReader; +import java.time.Duration; + +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1ConfigMap; +import io.kubernetes.client.util.Config; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.k3s.K3sContainer; + +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.cloud.kubernetes.integration.tests.commons.Commons; +import org.springframework.cloud.kubernetes.integration.tests.commons.native_client.Util; + +import static org.testcontainers.shaded.org.awaitility.Awaitility.await; + +/** + * @author wind57 + */ +@ExtendWith(OutputCaptureExtension.class) +abstract class K8sClientReloadBase { + + protected static final String NAMESPACE_RIGHT = "right"; + + protected static final K3sContainer K3S = Commons.container(); + + protected static Util util; + + @BeforeAll + protected static void beforeAll() { + K3S.start(); + util = new Util(K3S); + } + + protected static ApiClient apiClient() { + String kubeConfigYaml = K3S.getKubeConfigYaml(); + + ApiClient client; + try { + client = Config.fromConfig(new StringReader(kubeConfigYaml)); + } + catch (IOException e) { + throw new RuntimeException(e); + } + return new CoreV1Api(client).getApiClient(); + } + + /** + * assert that 'left' is present, and IFF it is, assert that 'right' is not + */ + static void assertReloadLogStatements(String left, String right, CapturedOutput output) { + + await().atMost(Duration.ofSeconds(30)) + .pollInterval(Duration.ofSeconds(1)) + .until(() -> { + boolean leftIsPresent = output.getOut().contains(left); + if (leftIsPresent) { + boolean rightIsPresent = output.getOut().contains(right); + return !rightIsPresent; + } + return false; + }); + } + + /** + * assert that 'left' is present, and IFF it is, assert that 'right' is not + */ + static void assertLogStatements(CapturedOutput output, String text) { + + await().atMost(Duration.ofSeconds(30)) + .pollInterval(Duration.ofSeconds(1)) + .until(() -> { + boolean leftIsPresent = output.getOut().contains(left); + if (leftIsPresent) { + boolean rightIsPresent = output.getOut().contains(right); + return !rightIsPresent; + } + return false; + }); + } + + protected static void replaceConfigMap(CoreV1Api api, V1ConfigMap configMap) { + try { + api.replaceNamespacedConfigMap(configMap.getMetadata().getName(), configMap.getMetadata().getNamespace(), + configMap, null, null, null, null); + } + catch (ApiException e) { + System.out.println(e.getResponseBody()); + throw new RuntimeException(e); + } + } + +} diff --git a/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/test/resources/META-INF/spring.factories b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/test/resources/META-INF/spring.factories new file mode 100644 index 00000000..ac5985bb --- /dev/null +++ b/spring-cloud-kubernetes-integration-tests/spring-cloud-kubernetes-k8s-client-reload/src/test/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.cloud.bootstrap.BootstrapConfiguration=\ +org.springframework.cloud.kubernetes.k8s.client.reload.it.configmap.K8sClientConfigMapEventTriggeredIT.TestConfig diff --git a/spring-cloud-kubernetes-test-support/src/main/java/org/springframework/cloud/kubernetes/integration/tests/commons/native_client/Util.java b/spring-cloud-kubernetes-test-support/src/main/java/org/springframework/cloud/kubernetes/integration/tests/commons/native_client/Util.java index 0c3cd190..9fc85e51 100644 --- a/spring-cloud-kubernetes-test-support/src/main/java/org/springframework/cloud/kubernetes/integration/tests/commons/native_client/Util.java +++ b/spring-cloud-kubernetes-test-support/src/main/java/org/springframework/cloud/kubernetes/integration/tests/commons/native_client/Util.java @@ -33,10 +33,12 @@ import io.kubernetes.client.custom.V1Patch; import io.kubernetes.client.openapi.ApiClient; import io.kubernetes.client.openapi.ApiException; import io.kubernetes.client.openapi.Configuration; +import io.kubernetes.client.openapi.apis.ApiregistrationV1Api; import io.kubernetes.client.openapi.apis.AppsV1Api; import io.kubernetes.client.openapi.apis.CoreV1Api; import io.kubernetes.client.openapi.apis.NetworkingV1Api; import io.kubernetes.client.openapi.apis.RbacAuthorizationV1Api; +import io.kubernetes.client.openapi.models.V1APIService; import io.kubernetes.client.openapi.models.V1ClusterRole; import io.kubernetes.client.openapi.models.V1ClusterRoleBinding; import io.kubernetes.client.openapi.models.V1ConfigMap; @@ -436,10 +438,38 @@ public final class Util { } public void deleteNamespace(String name) { + + // sometimes we get errors like : + + // "message": "Discovery failed for some groups, + // 1 failing: unable to retrieve the complete list of server APIs: + // metrics.k8s.io/v1beta1: stale GroupVersion discovery: metrics.k8s.io/v1beta1" + + // but even when it works OK, the finalizers are slowing down the deletion + ApiregistrationV1Api apiInstance = new ApiregistrationV1Api(coreV1Api.getApiClient()); + List