diff --git a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientConfigMapErrorOnReadingSourceTests.java b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientConfigMapErrorOnReadingSourceTests.java
new file mode 100644
index 0000000000..2c79b63c80
--- /dev/null
+++ b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientConfigMapErrorOnReadingSourceTests.java
@@ -0,0 +1,378 @@
+/*
+ * Copyright 2013-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.kubernetes.client.config;
+
+import java.util.List;
+import java.util.Map;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.client.WireMock;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.Configuration;
+import io.kubernetes.client.openapi.JSON;
+import io.kubernetes.client.openapi.apis.CoreV1Api;
+import io.kubernetes.client.openapi.models.V1ConfigMapBuilder;
+import io.kubernetes.client.openapi.models.V1ConfigMapList;
+import io.kubernetes.client.openapi.models.V1ObjectMetaBuilder;
+import io.kubernetes.client.util.ClientBuilder;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider;
+import org.springframework.cloud.kubernetes.commons.config.ConfigMapConfigProperties;
+import org.springframework.cloud.kubernetes.commons.config.Constants;
+import org.springframework.cloud.kubernetes.commons.config.RetryProperties;
+import org.springframework.core.env.CompositePropertySource;
+import org.springframework.core.env.MapPropertySource;
+import org.springframework.core.env.PropertySource;
+import org.springframework.mock.env.MockEnvironment;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * @author wind57
+ */
+@ExtendWith(OutputCaptureExtension.class)
+class KubernetesClientConfigMapErrorOnReadingSourceTests {
+
+ private static final V1ConfigMapList SINGLE_CONFIGMAP_LIST = new V1ConfigMapList()
+ .addItemsItem(new V1ConfigMapBuilder()
+ .withMetadata(
+ new V1ObjectMetaBuilder().withName("two").withNamespace("default").withResourceVersion("1").build())
+ .build());
+
+ private static final V1ConfigMapList DOUBLE_CONFIGMAP_LIST = new V1ConfigMapList()
+ .addItemsItem(new V1ConfigMapBuilder()
+ .withMetadata(
+ new V1ObjectMetaBuilder().withName("one").withNamespace("default").withResourceVersion("1").build())
+ .build())
+ .addItemsItem(new V1ConfigMapBuilder()
+ .withMetadata(
+ new V1ObjectMetaBuilder().withName("two").withNamespace("default").withResourceVersion("1").build())
+ .build());
+
+ private static WireMockServer wireMockServer;
+
+ @BeforeAll
+ public static void setup() {
+ wireMockServer = new WireMockServer(options().dynamicPort());
+
+ wireMockServer.start();
+ WireMock.configureFor("localhost", wireMockServer.port());
+
+ ApiClient client = new ClientBuilder().setBasePath("http://localhost:" + wireMockServer.port()).build();
+ client.setDebugging(true);
+ Configuration.setDefaultApiClient(client);
+ }
+
+ @AfterAll
+ public static void after() {
+ wireMockServer.stop();
+ }
+
+ @AfterEach
+ public void afterEach() {
+ WireMock.reset();
+ }
+
+ /**
+ *
+ * we try to read all config maps in a namespace and fail,
+ * thus generate a well defined name for the source.
+ *
+ */
+ @Test
+ void namedSingleConfigMapFails() {
+ String name = "my-config";
+ String namespace = "spring-k8s";
+ String path = "/api/v1/namespaces/" + namespace + "/configmaps";
+
+ stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error")));
+
+ ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(), List.of(),
+ Map.of(), true, name, namespace, false, true, false, RetryProperties.DEFAULT);
+
+ CoreV1Api api = new CoreV1Api();
+ KubernetesClientConfigMapPropertySourceLocator locator = new KubernetesClientConfigMapPropertySourceLocator(api,
+ configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment()));
+
+ CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment());
+ MapPropertySource mapPropertySource = (MapPropertySource) propertySource.getPropertySources()
+ .stream()
+ .findAny()
+ .orElseThrow();
+
+ assertThat(mapPropertySource.getName()).isEqualTo("configmap..spring-k8s");
+ assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true");
+
+ }
+
+ /**
+ *
+ * there are two sources and we try to read them.
+ * one fails and one passes.
+ *
+ */
+ @Test
+ void namedTwoConfigMapsOneFails(CapturedOutput output) {
+ String configMapNameOne = "one";
+ String configMapNameTwo = "two";
+ String namespace = "default";
+ String path = "/api/v1/namespaces/default/configmaps";
+
+ stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error"))
+ .inScenario("started")
+ .willSetStateTo("go-to-next"));
+
+ stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(SINGLE_CONFIGMAP_LIST)))
+ .inScenario("started")
+ .whenScenarioStateIs("go-to-next")
+ .willSetStateTo("done"));
+
+ ConfigMapConfigProperties.Source sourceOne = new ConfigMapConfigProperties.Source(configMapNameOne, namespace,
+ Map.of(), null, null, null);
+ ConfigMapConfigProperties.Source sourceTwo = new ConfigMapConfigProperties.Source(configMapNameTwo, namespace,
+ Map.of(), null, null, null);
+
+ ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(),
+ List.of(sourceOne, sourceTwo), Map.of(), true, null, namespace, false, true, false,
+ RetryProperties.DEFAULT);
+
+ CoreV1Api api = new CoreV1Api();
+ KubernetesClientConfigMapPropertySourceLocator locator = new KubernetesClientConfigMapPropertySourceLocator(api,
+ configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment()));
+
+ CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment());
+ List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList();
+
+ // two sources are present, one being empty
+ assertThat(names).containsExactly("configmap.two.default", "configmap..default");
+ assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true");
+ assertThat(output.getOut())
+ .doesNotContain("sourceName : two was requested, but not found in namespace : default");
+
+ }
+
+ /**
+ *
+ * there are two sources and we try to read them.
+ * both fail.
+ *
+ */
+ @Test
+ void namedTwoConfigMapsBothFail(CapturedOutput output) {
+ String configMapNameOne = "one";
+ String configMapNameTwo = "two";
+ String namespace = "default";
+ String path = "/api/v1/namespaces/default/configmaps";
+
+ stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error"))
+ .inScenario("started")
+ .willSetStateTo("go-to-next"));
+
+ stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error"))
+ .inScenario("started")
+ .whenScenarioStateIs("go-to-next")
+ .willSetStateTo("done"));
+
+ ConfigMapConfigProperties.Source sourceOne = new ConfigMapConfigProperties.Source(configMapNameOne, namespace,
+ Map.of(), null, null, null);
+ ConfigMapConfigProperties.Source sourceTwo = new ConfigMapConfigProperties.Source(configMapNameTwo, namespace,
+ Map.of(), null, null, null);
+
+ ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(),
+ List.of(sourceOne, sourceTwo), Map.of(), true, null, namespace, false, true, false,
+ RetryProperties.DEFAULT);
+
+ CoreV1Api api = new CoreV1Api();
+ KubernetesClientConfigMapPropertySourceLocator locator = new KubernetesClientConfigMapPropertySourceLocator(api,
+ configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment()));
+
+ CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment());
+ List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList();
+
+ assertThat(names).containsExactly("configmap..default");
+ assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true");
+ assertThat(output.getOut())
+ .doesNotContain("sourceName : one was requested, but not found in namespace : default");
+ assertThat(output.getOut())
+ .doesNotContain("sourceName : two was requested, but not found in namespace : default");
+ }
+
+ /**
+ *
+ * we try to read all config maps in a namespace and fail,
+ * thus generate a well defined name for the source.
+ *
+ */
+ @Test
+ void labeledSingleConfigMapFails(CapturedOutput output) {
+ Map labels = Map.of("a", "b");
+ String namespace = "spring-k8s";
+ String path = "/api/v1/namespaces/" + namespace + "/configmaps";
+
+ // one for the 'application' named configmap
+ // the other for the labeled config map
+ stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error"))
+ .inScenario("started")
+ .willSetStateTo("go-to-next"));
+
+ stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error"))
+ .inScenario("started")
+ .whenScenarioStateIs("go-to-next")
+ .willSetStateTo("done"));
+
+ ConfigMapConfigProperties.Source configMapSource = new ConfigMapConfigProperties.Source(null, namespace, labels,
+ null, null, null);
+
+ ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(),
+ List.of(configMapSource), labels, true, null, namespace, false, true, false, RetryProperties.DEFAULT);
+
+ CoreV1Api api = new CoreV1Api();
+ KubernetesClientConfigMapPropertySourceLocator locator = new KubernetesClientConfigMapPropertySourceLocator(api,
+ configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment()));
+
+ CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment());
+ List sourceNames = propertySource.getPropertySources().stream().map(PropertySource::getName).toList();
+
+ assertThat(sourceNames).containsExactly("configmap..spring-k8s");
+ assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true");
+ assertThat(output).contains("failure in reading labeled sources");
+ assertThat(output).contains("failure in reading named sources");
+ }
+
+ /**
+ *
+ * there are two sources and we try to read them.
+ * one fails and one passes.
+ *
+ */
+ @Test
+ void labeledTwoConfigMapsOneFails(CapturedOutput output) {
+
+ Map configMapOneLabels = Map.of("one", "1");
+ Map configMapTwoLabels = Map.of("two", "2");
+
+ String namespace = "default";
+ String path = "/api/v1/namespaces/default/configmaps";
+
+ // one for 'application' named configmap and one for the first labeled configmap
+ stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error"))
+ .inScenario("started")
+ .willSetStateTo("first"));
+
+ stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error"))
+ .inScenario("started")
+ .whenScenarioStateIs("first")
+ .willSetStateTo("second"));
+
+ // one that passes
+ stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(DOUBLE_CONFIGMAP_LIST)))
+ .inScenario("started")
+ .whenScenarioStateIs("second")
+ .willSetStateTo("done"));
+
+ ConfigMapConfigProperties.Source sourceOne = new ConfigMapConfigProperties.Source(null, namespace,
+ configMapOneLabels, null, null, null);
+ ConfigMapConfigProperties.Source sourceTwo = new ConfigMapConfigProperties.Source(null, namespace,
+ configMapTwoLabels, null, null, null);
+
+ ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(),
+ List.of(sourceOne, sourceTwo), Map.of("one", "1", "two", "2"), true, null, namespace, false, true,
+ false, RetryProperties.DEFAULT);
+
+ CoreV1Api api = new CoreV1Api();
+ KubernetesClientConfigMapPropertySourceLocator locator = new KubernetesClientConfigMapPropertySourceLocator(api,
+ configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment()));
+
+ CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment());
+ List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList();
+
+ // two sources are present, one being empty
+ assertThat(names).containsExactly("configmap.two.default", "configmap..default");
+ assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true");
+
+ assertThat(output).contains("failure in reading labeled sources");
+ assertThat(output).contains("failure in reading named sources");
+
+ }
+
+ /**
+ *
+ * there are two sources and we try to read them.
+ * both fail.
+ *
+ */
+ @Test
+ void labeledTwoConfigMapsBothFail(CapturedOutput output) {
+
+ Map configMapOneLabels = Map.of("one", "1");
+ Map configMapTwoLabels = Map.of("two", "2");
+
+ String namespace = "default";
+ String path = "/api/v1/namespaces/default/configmaps";
+
+ // one for 'application' named configmap and two for the labeled configmaps
+ stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error"))
+ .inScenario("started")
+ .willSetStateTo("first"));
+
+ stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error"))
+ .inScenario("started")
+ .whenScenarioStateIs("first")
+ .willSetStateTo("second"));
+
+ stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error"))
+ .inScenario("started")
+ .whenScenarioStateIs("second")
+ .willSetStateTo("done"));
+
+ ConfigMapConfigProperties.Source sourceOne = new ConfigMapConfigProperties.Source(null, namespace,
+ configMapOneLabels, null, null, null);
+ ConfigMapConfigProperties.Source sourceTwo = new ConfigMapConfigProperties.Source(null, namespace,
+ configMapTwoLabels, null, null, null);
+
+ ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(),
+ List.of(sourceOne, sourceTwo), Map.of("one", "1", "two", "2"), true, null, namespace, false, true,
+ false, RetryProperties.DEFAULT);
+
+ CoreV1Api api = new CoreV1Api();
+ KubernetesClientConfigMapPropertySourceLocator locator = new KubernetesClientConfigMapPropertySourceLocator(api,
+ configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment()));
+
+ CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment());
+ List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList();
+
+ // all 3 sources ('application' named source, and two labeled sources)
+ assertThat(names).containsExactly("configmap..default");
+ assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true");
+
+ assertThat(output).contains("failure in reading labeled sources");
+ assertThat(output).contains("failure in reading named sources");
+
+ }
+
+}
diff --git a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientSecretsPropertySourceLocatorTests.java b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientSecretsPropertySourceLocatorTests.java
index b9ecb61cc0..5bcf6ca005 100644
--- a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientSecretsPropertySourceLocatorTests.java
+++ b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientSecretsPropertySourceLocatorTests.java
@@ -54,28 +54,54 @@ class KubernetesClientSecretsPropertySourceLocatorTests {
private static final String LIST_API = "/api/v1/namespaces/default/secrets";
- private static final String LIST_BODY = "{\n" + "\t\"kind\": \"SecretList\",\n" + "\t\"apiVersion\": \"v1\",\n"
- + "\t\"metadata\": {\n" + "\t\t\"selfLink\": \"/api/v1/secrets\",\n"
- + "\t\t\"resourceVersion\": \"163035\"\n" + "\t},\n" + "\t\"items\": [{\n" + "\t\t\t\"metadata\": {\n"
- + "\t\t\t\t\"name\": \"db-secret\",\n" + "\t\t\t\t\"namespace\": \"default\",\n"
- + "\t\t\t\t\"selfLink\": \"/api/v1/namespaces/default/secrets/db-secret\",\n"
- + "\t\t\t\t\"uid\": \"59ba8e6a-a2d4-416c-b016-22597c193f23\",\n"
- + "\t\t\t\t\"resourceVersion\": \"1462\",\n" + "\t\t\t\t\"creationTimestamp\": \"2020-10-28T14:45:02Z\",\n"
- + "\t\t\t\t\"labels\": {\n" + "\t\t\t\t\t\"spring.cloud.kubernetes.secret\": \"true\"\n" + "\t\t\t\t}\n"
- + "\t\t\t},\n" + "\t\t\t\"data\": {\n" + "\t\t\t\t\"password\": \"cDQ1NXcwcmQ=\",\n"
- + "\t\t\t\t\"username\": \"dXNlcg==\"\n" + "\t\t\t},\n" + "\t\t\t\"type\": \"Opaque\"\n" + "\t\t},\n"
- + "\t\t{\n" + "\t\t\t\"metadata\": {\n" + "\t\t\t\t\"name\": \"rabbit-password\",\n"
- + "\t\t\t\t\"namespace\": \"default\",\n"
- + "\t\t\t\t\"selfLink\": \"/api/v1/namespaces/default/secrets/rabbit-password\",\n"
- + "\t\t\t\t\"uid\": \"bc211cb4-e7ff-4556-b26e-c54911301740\",\n"
- + "\t\t\t\t\"resourceVersion\": \"162708\",\n"
- + "\t\t\t\t\"creationTimestamp\": \"2020-10-29T19:47:36Z\",\n" + "\t\t\t\t\"labels\": {\n"
- + "\t\t\t\t\t\"spring.cloud.kubernetes.secret\": \"true\"\n" + "\t\t\t\t},\n"
- + "\t\t\t\t\"annotations\": {\n"
- + "\t\t\t\t\t\"kubectl.kubernetes.io/last-applied-configuration\": \"{\\\"apiVersion\\\":\\\"v1\\\",\\\"data\\\":{\\\"spring.rabbitmq.password\\\":\\\"password\\\"},\\\"kind\\\":\\\"Secret\\\",\\\"metadata\\\":{\\\"annotations\\\":{},\\\"labels\\\":{\\\"spring.cloud.kubernetes.secret\\\":\\\"true\\\"},\\\"name\\\":\\\"rabbit-password\\\",\\\"namespace\\\":\\\"default\\\"},\\\"type\\\":\\\"Opaque\\\"}\\n\"\n"
- + "\t\t\t\t}\n" + "\t\t\t},\n" + "\t\t\t\"data\": {\n"
- + "\t\t\t\t\"spring.rabbitmq.password\": \"cGFzc3dvcmQ=\"\n" + "\t\t\t},\n" + "\t\t\t\"type\": \"Opaque\"\n"
- + "\t\t}\n" + "\t]\n" + "}";
+ private static final String LIST_BODY = """
+ {
+ \t"kind": "SecretList",
+ \t"apiVersion": "v1",
+ \t"metadata": {
+ \t\t"selfLink": "/api/v1/secrets",
+ \t\t"resourceVersion": "163035"
+ \t},
+ \t"items": [{
+ \t\t\t"metadata": {
+ \t\t\t\t"name": "db-secret",
+ \t\t\t\t"namespace": "default",
+ \t\t\t\t"selfLink": "/api/v1/namespaces/default/secrets/db-secret",
+ \t\t\t\t"uid": "59ba8e6a-a2d4-416c-b016-22597c193f23",
+ \t\t\t\t"resourceVersion": "1462",
+ \t\t\t\t"creationTimestamp": "2020-10-28T14:45:02Z",
+ \t\t\t\t"labels": {
+ \t\t\t\t\t"spring.cloud.kubernetes.secret": "true"
+ \t\t\t\t}
+ \t\t\t},
+ \t\t\t"data": {
+ \t\t\t\t"password": "cDQ1NXcwcmQ=",
+ \t\t\t\t"username": "dXNlcg=="
+ \t\t\t},
+ \t\t\t"type": "Opaque"
+ \t\t},
+ \t\t{
+ \t\t\t"metadata": {
+ \t\t\t\t"name": "rabbit-password",
+ \t\t\t\t"namespace": "default",
+ \t\t\t\t"selfLink": "/api/v1/namespaces/default/secrets/rabbit-password",
+ \t\t\t\t"uid": "bc211cb4-e7ff-4556-b26e-c54911301740",
+ \t\t\t\t"resourceVersion": "162708",
+ \t\t\t\t"creationTimestamp": "2020-10-29T19:47:36Z",
+ \t\t\t\t"labels": {
+ \t\t\t\t\t"spring.cloud.kubernetes.secret": "true"
+ \t\t\t\t},
+ \t\t\t\t"annotations": {
+ \t\t\t\t\t"kubectl.kubernetes.io/last-applied-configuration": "{\\"apiVersion\\":\\"v1\\",\\"data\\":{\\"spring.rabbitmq.password\\":\\"password\\"},\\"kind\\":\\"Secret\\",\\"metadata\\":{\\"annotations\\":{},\\"labels\\":{\\"spring.cloud.kubernetes.secret\\":\\"true\\"},\\"name\\":\\"rabbit-password\\",\\"namespace\\":\\"default\\"},\\"type\\":\\"Opaque\\"}\\n"
+ \t\t\t\t}
+ \t\t\t},
+ \t\t\t"data": {
+ \t\t\t\t"spring.rabbitmq.password": "cGFzc3dvcmQ="
+ \t\t\t},
+ \t\t\t"type": "Opaque"
+ \t\t}
+ \t]
+ }""";
private static WireMockServer wireMockServer;
diff --git a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientSecretsPropertySourceTests.java b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientSecretsPropertySourceTests.java
index 4cc2b8d231..50173d3918 100644
--- a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientSecretsPropertySourceTests.java
+++ b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/KubernetesClientSecretsPropertySourceTests.java
@@ -80,28 +80,54 @@ class KubernetesClientSecretsPropertySourceTests {
private static final String LIST_API_WITH_LABEL = "/api/v1/namespaces/default/secrets";
- private static final String LIST_BODY = "{\n" + "\t\"kind\": \"SecretList\",\n" + "\t\"apiVersion\": \"v1\",\n"
- + "\t\"metadata\": {\n" + "\t\t\"selfLink\": \"/api/v1/secrets\",\n"
- + "\t\t\"resourceVersion\": \"163035\"\n" + "\t},\n" + "\t\"items\": [{\n" + "\t\t\t\"metadata\": {\n"
- + "\t\t\t\t\"name\": \"db-secret\",\n" + "\t\t\t\t\"namespace\": \"default\",\n"
- + "\t\t\t\t\"selfLink\": \"/api/v1/namespaces/default/secrets/db-secret\",\n"
- + "\t\t\t\t\"uid\": \"59ba8e6a-a2d4-416c-b016-22597c193f23\",\n"
- + "\t\t\t\t\"resourceVersion\": \"1462\",\n" + "\t\t\t\t\"creationTimestamp\": \"2020-10-28T14:45:02Z\",\n"
- + "\t\t\t\t\"labels\": {\n" + "\t\t\t\t\t\"spring.cloud.kubernetes.secret\": \"true\"\n" + "\t\t\t\t}\n"
- + "\t\t\t},\n" + "\t\t\t\"data\": {\n" + "\t\t\t\t\"password\": \"cDQ1NXcwcmQ=\",\n"
- + "\t\t\t\t\"username\": \"dXNlcg==\"\n" + "\t\t\t},\n" + "\t\t\t\"type\": \"Opaque\"\n" + "\t\t},\n"
- + "\t\t{\n" + "\t\t\t\"metadata\": {\n" + "\t\t\t\t\"name\": \"rabbit-password\",\n"
- + "\t\t\t\t\"namespace\": \"default\",\n"
- + "\t\t\t\t\"selfLink\": \"/api/v1/namespaces/default/secrets/rabbit-password\",\n"
- + "\t\t\t\t\"uid\": \"bc211cb4-e7ff-4556-b26e-c54911301740\",\n"
- + "\t\t\t\t\"resourceVersion\": \"162708\",\n"
- + "\t\t\t\t\"creationTimestamp\": \"2020-10-29T19:47:36Z\",\n" + "\t\t\t\t\"labels\": {\n"
- + "\t\t\t\t\t\"spring.cloud.kubernetes.secret\": \"true\"\n" + "\t\t\t\t},\n"
- + "\t\t\t\t\"annotations\": {\n"
- + "\t\t\t\t\t\"kubectl.kubernetes.io/last-applied-configuration\": \"{\\\"apiVersion\\\":\\\"v1\\\",\\\"data\\\":{\\\"spring.rabbitmq.password\\\":\\\"password\\\"},\\\"kind\\\":\\\"Secret\\\",\\\"metadata\\\":{\\\"annotations\\\":{},\\\"labels\\\":{\\\"spring.cloud.kubernetes.secret\\\":\\\"true\\\"},\\\"name\\\":\\\"rabbit-password\\\",\\\"namespace\\\":\\\"default\\\"},\\\"type\\\":\\\"Opaque\\\"}\\n\"\n"
- + "\t\t\t\t}\n" + "\t\t\t},\n" + "\t\t\t\"data\": {\n"
- + "\t\t\t\t\"spring.rabbitmq.password\": \"cGFzc3dvcmQ=\"\n" + "\t\t\t},\n" + "\t\t\t\"type\": \"Opaque\"\n"
- + "\t\t}\n" + "\t]\n" + "}";
+ private static final String LIST_BODY = """
+ {
+ \t"kind": "SecretList",
+ \t"apiVersion": "v1",
+ \t"metadata": {
+ \t\t"selfLink": "/api/v1/secrets",
+ \t\t"resourceVersion": "163035"
+ \t},
+ \t"items": [{
+ \t\t\t"metadata": {
+ \t\t\t\t"name": "db-secret",
+ \t\t\t\t"namespace": "default",
+ \t\t\t\t"selfLink": "/api/v1/namespaces/default/secrets/db-secret",
+ \t\t\t\t"uid": "59ba8e6a-a2d4-416c-b016-22597c193f23",
+ \t\t\t\t"resourceVersion": "1462",
+ \t\t\t\t"creationTimestamp": "2020-10-28T14:45:02Z",
+ \t\t\t\t"labels": {
+ \t\t\t\t\t"spring.cloud.kubernetes.secret": "true"
+ \t\t\t\t}
+ \t\t\t},
+ \t\t\t"data": {
+ \t\t\t\t"password": "cDQ1NXcwcmQ=",
+ \t\t\t\t"username": "dXNlcg=="
+ \t\t\t},
+ \t\t\t"type": "Opaque"
+ \t\t},
+ \t\t{
+ \t\t\t"metadata": {
+ \t\t\t\t"name": "rabbit-password",
+ \t\t\t\t"namespace": "default",
+ \t\t\t\t"selfLink": "/api/v1/namespaces/default/secrets/rabbit-password",
+ \t\t\t\t"uid": "bc211cb4-e7ff-4556-b26e-c54911301740",
+ \t\t\t\t"resourceVersion": "162708",
+ \t\t\t\t"creationTimestamp": "2020-10-29T19:47:36Z",
+ \t\t\t\t"labels": {
+ \t\t\t\t\t"spring.cloud.kubernetes.secret": "true"
+ \t\t\t\t},
+ \t\t\t\t"annotations": {
+ \t\t\t\t\t"kubectl.kubernetes.io/last-applied-configuration": "{\\"apiVersion\\":\\"v1\\",\\"data\\":{\\"spring.rabbitmq.password\\":\\"password\\"},\\"kind\\":\\"Secret\\",\\"metadata\\":{\\"annotations\\":{},\\"labels\\":{\\"spring.cloud.kubernetes.secret\\":\\"true\\"},\\"name\\":\\"rabbit-password\\",\\"namespace\\":\\"default\\"},\\"type\\":\\"Opaque\\"}\\n"
+ \t\t\t\t}
+ \t\t\t},
+ \t\t\t"data": {
+ \t\t\t\t"spring.rabbitmq.password": "cGFzc3dvcmQ="
+ \t\t\t},
+ \t\t\t"type": "Opaque"
+ \t\t}
+ \t]
+ }""";
private static WireMockServer wireMockServer;
diff --git a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/VisibleKubernetesClientEventBasedConfigMapChangeDetector.java b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/VisibleKubernetesClientEventBasedConfigMapChangeDetector.java
new file mode 100644
index 0000000000..3045521363
--- /dev/null
+++ b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/VisibleKubernetesClientEventBasedConfigMapChangeDetector.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2013-2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.kubernetes.client.config;
+
+import io.kubernetes.client.common.KubernetesObject;
+import io.kubernetes.client.openapi.apis.CoreV1Api;
+
+import org.springframework.cloud.kubernetes.client.config.reload.KubernetesClientEventBasedConfigMapChangeDetector;
+import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider;
+import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties;
+import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy;
+import org.springframework.core.env.ConfigurableEnvironment;
+
+/**
+ * @author wind57
+ */
+public class VisibleKubernetesClientEventBasedConfigMapChangeDetector
+ extends KubernetesClientEventBasedConfigMapChangeDetector {
+
+ public VisibleKubernetesClientEventBasedConfigMapChangeDetector(CoreV1Api coreV1Api,
+ ConfigurableEnvironment environment, ConfigReloadProperties properties,
+ ConfigurationUpdateStrategy strategy, KubernetesClientConfigMapPropertySourceLocator propertySourceLocator,
+ KubernetesNamespaceProvider kubernetesNamespaceProvider) {
+ super(coreV1Api, environment, properties, strategy, propertySourceLocator, kubernetesNamespaceProvider);
+ }
+
+ public void onEvent(KubernetesObject kubernetesObject) {
+ super.onEvent(kubernetesObject);
+ }
+
+}
diff --git a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/VisibleKubernetesClientEventBasedSecretsChangeDetector.java b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/VisibleKubernetesClientEventBasedSecretsChangeDetector.java
new file mode 100644
index 0000000000..8cc7540e35
--- /dev/null
+++ b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/VisibleKubernetesClientEventBasedSecretsChangeDetector.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2013-2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.kubernetes.client.config;
+
+import io.kubernetes.client.common.KubernetesObject;
+import io.kubernetes.client.openapi.apis.CoreV1Api;
+
+import org.springframework.cloud.kubernetes.client.config.reload.KubernetesClientEventBasedSecretsChangeDetector;
+import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider;
+import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties;
+import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy;
+import org.springframework.core.env.ConfigurableEnvironment;
+
+/**
+ * @author wind57
+ */
+public class VisibleKubernetesClientEventBasedSecretsChangeDetector
+ extends KubernetesClientEventBasedSecretsChangeDetector {
+
+ public VisibleKubernetesClientEventBasedSecretsChangeDetector(CoreV1Api coreV1Api,
+ ConfigurableEnvironment environment, ConfigReloadProperties properties,
+ ConfigurationUpdateStrategy strategy, KubernetesClientSecretsPropertySourceLocator propertySourceLocator,
+ KubernetesNamespaceProvider kubernetesNamespaceProvider) {
+ super(coreV1Api, environment, properties, strategy, propertySourceLocator, kubernetesNamespaceProvider);
+ }
+
+ @Override
+ public void onEvent(KubernetesObject kubernetesObject) {
+ super.onEvent(kubernetesObject);
+ }
+
+}
diff --git a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/EventReloadConfigMapTest.java b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/EventReloadConfigMapTest.java
new file mode 100644
index 0000000000..a0d458f710
--- /dev/null
+++ b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/EventReloadConfigMapTest.java
@@ -0,0 +1,255 @@
+/*
+ * Copyright 2013-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.kubernetes.client.config.reload_it;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.client.WireMock;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.Configuration;
+import io.kubernetes.client.openapi.JSON;
+import io.kubernetes.client.openapi.apis.CoreV1Api;
+import io.kubernetes.client.openapi.models.V1ConfigMap;
+import io.kubernetes.client.openapi.models.V1ConfigMapBuilder;
+import io.kubernetes.client.openapi.models.V1ConfigMapList;
+import io.kubernetes.client.util.ClientBuilder;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.cloud.kubernetes.client.KubernetesClientUtils;
+import org.springframework.cloud.kubernetes.client.config.KubernetesClientConfigMapPropertySourceLocator;
+import org.springframework.cloud.kubernetes.client.config.VisibleKubernetesClientEventBasedConfigMapChangeDetector;
+import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider;
+import org.springframework.cloud.kubernetes.commons.config.ConfigMapConfigProperties;
+import org.springframework.cloud.kubernetes.commons.config.RetryProperties;
+import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties;
+import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Primary;
+import org.springframework.core.env.AbstractEnvironment;
+import org.springframework.core.env.PropertySource;
+import org.springframework.mock.env.MockEnvironment;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
+
+/**
+ * @author wind57
+ */
+@SpringBootTest(
+ properties = { "spring.main.allow-bean-definition-overriding=true",
+ "logging.level.org.springframework.cloud.kubernetes.commons.config=debug" },
+ classes = { EventReloadConfigMapTest.TestConfig.class })
+@ExtendWith(OutputCaptureExtension.class)
+class EventReloadConfigMapTest {
+
+ private static final boolean FAIL_FAST = false;
+
+ private static WireMockServer wireMockServer;
+
+ private static final String CONFIG_MAP_NAME = "mine";
+
+ private static final String NAMESPACE = "spring-k8s";
+
+ private static final boolean[] strategyCalled = new boolean[] { false };
+
+ private static CoreV1Api coreV1Api;
+
+ private static final MockedStatic MOCK_STATIC = Mockito
+ .mockStatic(KubernetesClientUtils.class);
+
+ @Autowired
+ private VisibleKubernetesClientEventBasedConfigMapChangeDetector kubernetesClientEventBasedConfigMapChangeDetector;
+
+ @BeforeAll
+ static void setup() {
+ wireMockServer = new WireMockServer(options().dynamicPort());
+
+ wireMockServer.start();
+ WireMock.configureFor("localhost", wireMockServer.port());
+
+ ApiClient client = new ClientBuilder().setBasePath("http://localhost:" + wireMockServer.port()).build();
+ client.setDebugging(true);
+ MOCK_STATIC.when(KubernetesClientUtils::createApiClientForInformerClient).thenReturn(client);
+ MOCK_STATIC
+ .when(() -> KubernetesClientUtils.getApplicationNamespace(Mockito.anyString(), Mockito.anyString(),
+ Mockito.any()))
+ .thenReturn(NAMESPACE);
+ Configuration.setDefaultApiClient(client);
+ coreV1Api = new CoreV1Api();
+
+ String path = "/api/v1/namespaces/spring-k8s/configmaps";
+ V1ConfigMap configMapOne = configMap(CONFIG_MAP_NAME, Map.of());
+ V1ConfigMapList listOne = new V1ConfigMapList().addItemsItem(configMapOne);
+
+ // needed so that our environment is populated with 'something'
+ // this call is done in the method that returns the AbstractEnvironment
+ stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(listOne)))
+ .inScenario("mine-test")
+ .willSetStateTo("go-to-fail"));
+
+ // first call will fail
+ stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error"))
+ .inScenario("mine-test")
+ .whenScenarioStateIs("go-to-fail")
+ .willSetStateTo("go-to-ok"));
+
+ // second call passes (change data so that reload is triggered)
+ configMapOne = configMap(CONFIG_MAP_NAME, Map.of("a", "b"));
+ listOne = new V1ConfigMapList().addItemsItem(configMapOne);
+ stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(listOne)))
+ .inScenario("mine-test")
+ .whenScenarioStateIs("go-to-ok")
+ .willSetStateTo("done"));
+ }
+
+ @AfterAll
+ static void after() {
+ MOCK_STATIC.close();
+ wireMockServer.stop();
+ }
+
+ /**
+ *
+ * - 'configmap.mine.spring-k8s' already exists in the environment
+ * - we simulate that another configmap is created, so a request goes to k8s to find any potential
+ * differences. This request is mocked to fail.
+ * - then our configmap is changed and the request passes
+ *
+ */
+ @Test
+ void test(CapturedOutput output) {
+ V1ConfigMap configMapNotMine = configMap("not" + CONFIG_MAP_NAME, Map.of());
+ kubernetesClientEventBasedConfigMapChangeDetector.onEvent(configMapNotMine);
+
+ // we fail while reading 'configMapOne'
+ Awaitility.await().atMost(Duration.ofSeconds(10)).pollInterval(Duration.ofSeconds(1)).until(() -> {
+ boolean one = output.getOut().contains("failure in reading named sources");
+ boolean two = output.getOut()
+ .contains("there was an error while reading config maps/secrets, no reload will happen");
+ boolean three = output.getOut()
+ .contains("reloadable condition was not satisfied, reload will not be triggered");
+ boolean updateStrategyNotCalled = !strategyCalled[0];
+ return one && two && three && updateStrategyNotCalled;
+ });
+
+ // trigger the call again
+ V1ConfigMap configMapMine = configMap(CONFIG_MAP_NAME, Map.of());
+ kubernetesClientEventBasedConfigMapChangeDetector.onEvent(configMapMine);
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(10))
+ .pollInterval(Duration.ofSeconds(1))
+ .until(() -> strategyCalled[0]);
+ }
+
+ private static V1ConfigMap configMap(String name, Map data) {
+ return new V1ConfigMapBuilder().withNewMetadata().withName(name).endMetadata().withData(data).build();
+ }
+
+ @TestConfiguration
+ static class TestConfig {
+
+ @Bean
+ @Primary
+ VisibleKubernetesClientEventBasedConfigMapChangeDetector kubernetesClientEventBasedConfigMapChangeDetector(
+ AbstractEnvironment environment, ConfigReloadProperties configReloadProperties,
+ ConfigurationUpdateStrategy configurationUpdateStrategy,
+ KubernetesClientConfigMapPropertySourceLocator kubernetesClientConfigMapPropertySourceLocator,
+ KubernetesNamespaceProvider namespaceProvider) {
+ return new VisibleKubernetesClientEventBasedConfigMapChangeDetector(coreV1Api, environment,
+ configReloadProperties, configurationUpdateStrategy, kubernetesClientConfigMapPropertySourceLocator,
+ namespaceProvider);
+ }
+
+ @Bean
+ @Primary
+ AbstractEnvironment environment() {
+ MockEnvironment mockEnvironment = new MockEnvironment();
+ mockEnvironment.setProperty("spring.cloud.kubernetes.client.namespace", NAMESPACE);
+
+ // simulate that environment already has a
+ // KubernetesClientConfigMapPropertySource,
+ // otherwise we can't properly test reload functionality
+ ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(),
+ List.of(), Map.of(), true, CONFIG_MAP_NAME, NAMESPACE, false, true, FAIL_FAST,
+ RetryProperties.DEFAULT);
+ KubernetesNamespaceProvider namespaceProvider = new KubernetesNamespaceProvider(mockEnvironment);
+
+ PropertySource> propertySource = new KubernetesClientConfigMapPropertySourceLocator(coreV1Api,
+ configMapConfigProperties, namespaceProvider)
+ .locate(mockEnvironment);
+
+ mockEnvironment.getPropertySources().addFirst(propertySource);
+ return mockEnvironment;
+ }
+
+ @Bean
+ @Primary
+ ConfigReloadProperties configReloadProperties() {
+ return new ConfigReloadProperties(true, true, false, ConfigReloadProperties.ReloadStrategy.REFRESH,
+ ConfigReloadProperties.ReloadDetectionMode.POLLING, Duration.ofMillis(2000), Set.of("non-default"),
+ false, Duration.ofSeconds(2));
+ }
+
+ @Bean
+ @Primary
+ ConfigMapConfigProperties configMapConfigProperties() {
+ return new ConfigMapConfigProperties(true, List.of(), List.of(), Map.of(), true, CONFIG_MAP_NAME, NAMESPACE,
+ false, true, FAIL_FAST, RetryProperties.DEFAULT);
+ }
+
+ @Bean
+ @Primary
+ KubernetesNamespaceProvider namespaceProvider(AbstractEnvironment environment) {
+ return new KubernetesNamespaceProvider(environment);
+ }
+
+ @Bean
+ @Primary
+ ConfigurationUpdateStrategy configurationUpdateStrategy() {
+ return new ConfigurationUpdateStrategy("to-console", () -> {
+ strategyCalled[0] = true;
+ });
+ }
+
+ @Bean
+ @Primary
+ KubernetesClientConfigMapPropertySourceLocator kubernetesClientConfigMapPropertySourceLocator(
+ ConfigMapConfigProperties configMapConfigProperties, KubernetesNamespaceProvider namespaceProvider) {
+ return new KubernetesClientConfigMapPropertySourceLocator(coreV1Api, configMapConfigProperties,
+ namespaceProvider);
+ }
+
+ }
+
+}
diff --git a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/EventReloadSecretTest.java b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/EventReloadSecretTest.java
new file mode 100644
index 0000000000..cc4af32b82
--- /dev/null
+++ b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/EventReloadSecretTest.java
@@ -0,0 +1,260 @@
+/*
+ * Copyright 2013-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.kubernetes.client.config.reload_it;
+
+import java.time.Duration;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.client.WireMock;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.Configuration;
+import io.kubernetes.client.openapi.JSON;
+import io.kubernetes.client.openapi.apis.CoreV1Api;
+import io.kubernetes.client.openapi.models.V1Secret;
+import io.kubernetes.client.openapi.models.V1SecretBuilder;
+import io.kubernetes.client.openapi.models.V1SecretList;
+import io.kubernetes.client.util.ClientBuilder;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.cloud.kubernetes.client.KubernetesClientUtils;
+import org.springframework.cloud.kubernetes.client.config.KubernetesClientSecretsPropertySourceLocator;
+import org.springframework.cloud.kubernetes.client.config.VisibleKubernetesClientEventBasedSecretsChangeDetector;
+import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider;
+import org.springframework.cloud.kubernetes.commons.config.RetryProperties;
+import org.springframework.cloud.kubernetes.commons.config.SecretsConfigProperties;
+import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties;
+import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Primary;
+import org.springframework.core.env.AbstractEnvironment;
+import org.springframework.core.env.PropertySource;
+import org.springframework.mock.env.MockEnvironment;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
+
+/**
+ * @author wind57
+ */
+@SpringBootTest(
+ properties = { "spring.main.allow-bean-definition-overriding=true",
+ "logging.level.org.springframework.cloud.kubernetes.commons.config=debug" },
+ classes = { EventReloadSecretTest.TestConfig.class })
+@ExtendWith(OutputCaptureExtension.class)
+class EventReloadSecretTest {
+
+ private static final boolean FAIL_FAST = false;
+
+ private static WireMockServer wireMockServer;
+
+ private static final String SECRET_NAME = "mine";
+
+ private static final String NAMESPACE = "spring-k8s";
+
+ private static final boolean[] strategyCalled = new boolean[] { false };
+
+ private static CoreV1Api coreV1Api;
+
+ private static final MockedStatic MOCK_STATIC = Mockito
+ .mockStatic(KubernetesClientUtils.class);
+
+ @Autowired
+ private VisibleKubernetesClientEventBasedSecretsChangeDetector kubernetesClientEventBasedSecretsChangeDetector;
+
+ @BeforeAll
+ static void setup() {
+ wireMockServer = new WireMockServer(options().dynamicPort());
+
+ wireMockServer.start();
+ WireMock.configureFor("localhost", wireMockServer.port());
+
+ ApiClient client = new ClientBuilder().setBasePath("http://localhost:" + wireMockServer.port()).build();
+ client.setDebugging(true);
+ MOCK_STATIC.when(KubernetesClientUtils::createApiClientForInformerClient).thenReturn(client);
+ MOCK_STATIC
+ .when(() -> KubernetesClientUtils.getApplicationNamespace(Mockito.anyString(), Mockito.anyString(),
+ Mockito.any()))
+ .thenReturn(NAMESPACE);
+ Configuration.setDefaultApiClient(client);
+ coreV1Api = new CoreV1Api();
+
+ String path = "/api/v1/namespaces/spring-k8s/secrets";
+ V1Secret secretOne = secret(SECRET_NAME, Map.of());
+ V1SecretList listOne = new V1SecretList().addItemsItem(secretOne);
+
+ // needed so that our environment is populated with 'something'
+ // this call is done in the method that returns the AbstractEnvironment
+ stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(listOne)))
+ .inScenario("mine-test")
+ .willSetStateTo("go-to-fail"));
+
+ // first call will fail
+ stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error"))
+ .inScenario("mine-test")
+ .whenScenarioStateIs("go-to-fail")
+ .willSetStateTo("go-to-ok"));
+
+ // second call passes (change data so that reload is triggered)
+ secretOne = secret(SECRET_NAME, Map.of("a", "b"));
+ listOne = new V1SecretList().addItemsItem(secretOne);
+ stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(listOne)))
+ .inScenario("mine-test")
+ .whenScenarioStateIs("go-to-ok")
+ .willSetStateTo("done"));
+ }
+
+ @AfterAll
+ static void after() {
+ MOCK_STATIC.close();
+ wireMockServer.stop();
+ }
+
+ /**
+ *
+ * - 'secret.mine.spring-k8s' already exists in the environment
+ * - we simulate that another secret is created, so a request goes to k8s to find any potential
+ * differences. This request is mocked to fail.
+ * - then our secret is changed and the request passes
+ *
+ */
+ @Test
+ void test(CapturedOutput output) {
+ V1Secret secretNotMine = secret("not" + SECRET_NAME, Map.of());
+ kubernetesClientEventBasedSecretsChangeDetector.onEvent(secretNotMine);
+
+ // we fail while reading 'configMapOne'
+ Awaitility.await().atMost(Duration.ofSeconds(10)).pollInterval(Duration.ofSeconds(1)).until(() -> {
+ boolean one = output.getOut().contains("failure in reading named sources");
+ boolean two = output.getOut()
+ .contains("there was an error while reading config maps/secrets, no reload will happen");
+ boolean three = output.getOut()
+ .contains("reloadable condition was not satisfied, reload will not be triggered");
+ boolean updateStrategyNotCalled = !strategyCalled[0];
+ return one && two && three && updateStrategyNotCalled;
+ });
+
+ // trigger the call again
+ V1Secret secretMine = secret(SECRET_NAME, Map.of());
+ kubernetesClientEventBasedSecretsChangeDetector.onEvent(secretMine);
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(10))
+ .pollInterval(Duration.ofSeconds(1))
+ .until(() -> strategyCalled[0]);
+ }
+
+ private static V1Secret secret(String name, Map data) {
+ Map encoded = data.entrySet()
+ .stream()
+ .collect(Collectors.toMap(Map.Entry::getKey, e -> Base64.getEncoder().encode(e.getValue().getBytes())));
+
+ return new V1SecretBuilder().withNewMetadata().withName(name).endMetadata().withData(encoded).build();
+ }
+
+ @TestConfiguration
+ static class TestConfig {
+
+ @Bean
+ @Primary
+ VisibleKubernetesClientEventBasedSecretsChangeDetector kubernetesClientEventBasedSecretsChangeDetector(
+ AbstractEnvironment environment, ConfigReloadProperties configReloadProperties,
+ ConfigurationUpdateStrategy configurationUpdateStrategy,
+ KubernetesClientSecretsPropertySourceLocator kubernetesClientSecretsPropertySourceLocator,
+ KubernetesNamespaceProvider namespaceProvider) {
+ return new VisibleKubernetesClientEventBasedSecretsChangeDetector(coreV1Api, environment,
+ configReloadProperties, configurationUpdateStrategy, kubernetesClientSecretsPropertySourceLocator,
+ namespaceProvider);
+ }
+
+ @Bean
+ @Primary
+ AbstractEnvironment environment() {
+ MockEnvironment mockEnvironment = new MockEnvironment();
+ mockEnvironment.setProperty("spring.cloud.kubernetes.client.namespace", NAMESPACE);
+
+ // simulate that environment already has a
+ // KubernetesClientConfigMapPropertySource,
+ // otherwise we can't properly test reload functionality
+ SecretsConfigProperties secretsConfigProperties = new SecretsConfigProperties(true, Map.of(), List.of(),
+ List.of(), true, SECRET_NAME, NAMESPACE, false, true, FAIL_FAST, RetryProperties.DEFAULT);
+ KubernetesNamespaceProvider namespaceProvider = new KubernetesNamespaceProvider(mockEnvironment);
+
+ PropertySource> propertySource = new KubernetesClientSecretsPropertySourceLocator(coreV1Api,
+ namespaceProvider, secretsConfigProperties)
+ .locate(mockEnvironment);
+
+ mockEnvironment.getPropertySources().addFirst(propertySource);
+ return mockEnvironment;
+ }
+
+ @Bean
+ @Primary
+ ConfigReloadProperties configReloadProperties() {
+ return new ConfigReloadProperties(true, true, false, ConfigReloadProperties.ReloadStrategy.REFRESH,
+ ConfigReloadProperties.ReloadDetectionMode.POLLING, Duration.ofMillis(2000), Set.of("non-default"),
+ false, Duration.ofSeconds(2));
+ }
+
+ @Bean
+ @Primary
+ SecretsConfigProperties secretsConfigProperties() {
+ return new SecretsConfigProperties(true, Map.of(), List.of(), List.of(), true, SECRET_NAME, NAMESPACE,
+ false, true, FAIL_FAST, RetryProperties.DEFAULT);
+ }
+
+ @Bean
+ @Primary
+ KubernetesNamespaceProvider namespaceProvider(AbstractEnvironment environment) {
+ return new KubernetesNamespaceProvider(environment);
+ }
+
+ @Bean
+ @Primary
+ ConfigurationUpdateStrategy configurationUpdateStrategy() {
+ return new ConfigurationUpdateStrategy("to-console", () -> {
+ strategyCalled[0] = true;
+ });
+ }
+
+ @Bean
+ @Primary
+ KubernetesClientSecretsPropertySourceLocator kubernetesClientSecretsPropertySourceLocator(
+ SecretsConfigProperties secretsConfigProperties, KubernetesNamespaceProvider namespaceProvider) {
+ return new KubernetesClientSecretsPropertySourceLocator(coreV1Api, namespaceProvider,
+ secretsConfigProperties);
+ }
+
+ }
+
+}
diff --git a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/PollingReloadConfigMapTest.java b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/PollingReloadConfigMapTest.java
new file mode 100644
index 0000000000..0e55494d6e
--- /dev/null
+++ b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/PollingReloadConfigMapTest.java
@@ -0,0 +1,234 @@
+/*
+ * Copyright 2013-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.kubernetes.client.config.reload_it;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.client.WireMock;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.Configuration;
+import io.kubernetes.client.openapi.JSON;
+import io.kubernetes.client.openapi.apis.CoreV1Api;
+import io.kubernetes.client.openapi.models.V1ConfigMap;
+import io.kubernetes.client.openapi.models.V1ConfigMapBuilder;
+import io.kubernetes.client.openapi.models.V1ConfigMapList;
+import io.kubernetes.client.util.ClientBuilder;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.cloud.kubernetes.client.config.KubernetesClientConfigMapPropertySource;
+import org.springframework.cloud.kubernetes.client.config.KubernetesClientConfigMapPropertySourceLocator;
+import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider;
+import org.springframework.cloud.kubernetes.commons.config.ConfigMapConfigProperties;
+import org.springframework.cloud.kubernetes.commons.config.RetryProperties;
+import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties;
+import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy;
+import org.springframework.cloud.kubernetes.commons.config.reload.PollingConfigMapChangeDetector;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Primary;
+import org.springframework.core.env.AbstractEnvironment;
+import org.springframework.core.env.PropertySource;
+import org.springframework.mock.env.MockEnvironment;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
+
+/**
+ * @author wind57
+ */
+@SpringBootTest(
+ properties = { "spring.main.allow-bean-definition-overriding=true",
+ "logging.level.org.springframework.cloud.kubernetes.commons.config=debug" },
+ classes = { PollingReloadConfigMapTest.TestConfig.class })
+@ExtendWith(OutputCaptureExtension.class)
+class PollingReloadConfigMapTest {
+
+ private static WireMockServer wireMockServer;
+
+ private static final boolean FAIL_FAST = false;
+
+ private static final String CONFIG_MAP_NAME = "mine";
+
+ private static final String NAMESPACE = "spring-k8s";
+
+ private static final boolean[] strategyCalled = new boolean[] { false };
+
+ private static CoreV1Api coreV1Api;
+
+ @BeforeAll
+ static void setup() {
+ wireMockServer = new WireMockServer(options().dynamicPort());
+
+ wireMockServer.start();
+ WireMock.configureFor("localhost", wireMockServer.port());
+
+ ApiClient client = new ClientBuilder().setBasePath("http://localhost:" + wireMockServer.port()).build();
+ client.setDebugging(true);
+ Configuration.setDefaultApiClient(client);
+ coreV1Api = new CoreV1Api();
+
+ String path = "/api/v1/namespaces/spring-k8s/configmaps";
+ V1ConfigMap configMapOne = configMap(CONFIG_MAP_NAME, Map.of());
+ V1ConfigMapList listOne = new V1ConfigMapList().addItemsItem(configMapOne);
+
+ // needed so that our environment is populated with 'something'
+ // this call is done in the method that returns the AbstractEnvironment
+ stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(listOne)))
+ .inScenario("my-test")
+ .willSetStateTo("go-to-fail"));
+
+ // first reload call fails
+ stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error"))
+ .inScenario("my-test")
+ .whenScenarioStateIs("go-to-fail")
+ .willSetStateTo("go-to-ok"));
+
+ // second reload call passes
+ V1ConfigMap configMapTwo = configMap(CONFIG_MAP_NAME, Map.of("a", "b"));
+ V1ConfigMapList listTwo = new V1ConfigMapList().addItemsItem(configMapTwo);
+ stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(listTwo)))
+ .inScenario("my-test")
+ .whenScenarioStateIs("go-to-ok"));
+
+ }
+
+ @AfterAll
+ static void after() {
+ wireMockServer.stop();
+ }
+
+ /**
+ *
+ * - we have a PropertySource in the environment
+ * - first polling cycle tries to read the sources from k8s and fails
+ * - second polling cycle reads sources from k8s and finds a change
+ *
+ */
+ @Test
+ void test(CapturedOutput output) {
+ // we fail while reading 'configMapOne'
+ Awaitility.await().atMost(Duration.ofSeconds(10)).pollInterval(Duration.ofSeconds(1)).until(() -> {
+ boolean one = output.getOut().contains("failure in reading named sources");
+ boolean two = output.getOut()
+ .contains("there was an error while reading config maps/secrets, no reload will happen");
+ boolean three = output.getOut()
+ .contains("reloadable condition was not satisfied, reload will not be triggered");
+ boolean updateStrategyNotCalled = !strategyCalled[0];
+ return one && two && three && updateStrategyNotCalled;
+ });
+
+ // it passes while reading 'configMapTwo'
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(10))
+ .pollInterval(Duration.ofSeconds(1))
+ .until(() -> strategyCalled[0]);
+ }
+
+ private static V1ConfigMap configMap(String name, Map data) {
+ return new V1ConfigMapBuilder().withNewMetadata().withName(name).endMetadata().withData(data).build();
+ }
+
+ @TestConfiguration
+ static class TestConfig {
+
+ @Bean
+ @Primary
+ PollingConfigMapChangeDetector pollingConfigMapChangeDetector(AbstractEnvironment environment,
+ ConfigReloadProperties configReloadProperties, ConfigurationUpdateStrategy configurationUpdateStrategy,
+ KubernetesClientConfigMapPropertySourceLocator kubernetesClientConfigMapPropertySourceLocator) {
+ ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
+ scheduler.initialize();
+ return new PollingConfigMapChangeDetector(environment, configReloadProperties, configurationUpdateStrategy,
+ KubernetesClientConfigMapPropertySource.class, kubernetesClientConfigMapPropertySourceLocator,
+ scheduler);
+ }
+
+ @Bean
+ @Primary
+ AbstractEnvironment environment() {
+ MockEnvironment mockEnvironment = new MockEnvironment();
+ mockEnvironment.setProperty("spring.cloud.kubernetes.client.namespace", NAMESPACE);
+
+ // simulate that environment already has a
+ // KubernetesClientConfigMapPropertySource,
+ // otherwise we can't properly test reload functionality
+ ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(),
+ List.of(), Map.of(), true, CONFIG_MAP_NAME, NAMESPACE, false, true, true, RetryProperties.DEFAULT);
+ KubernetesNamespaceProvider namespaceProvider = new KubernetesNamespaceProvider(mockEnvironment);
+
+ PropertySource> propertySource = new KubernetesClientConfigMapPropertySourceLocator(coreV1Api,
+ configMapConfigProperties, namespaceProvider)
+ .locate(mockEnvironment);
+
+ mockEnvironment.getPropertySources().addFirst(propertySource);
+ return mockEnvironment;
+ }
+
+ @Bean
+ @Primary
+ ConfigReloadProperties configReloadProperties() {
+ return new ConfigReloadProperties(true, true, false, ConfigReloadProperties.ReloadStrategy.REFRESH,
+ ConfigReloadProperties.ReloadDetectionMode.POLLING, Duration.ofMillis(2000), Set.of("non-default"),
+ false, Duration.ofSeconds(2));
+ }
+
+ @Bean
+ @Primary
+ ConfigMapConfigProperties configMapConfigProperties() {
+ return new ConfigMapConfigProperties(true, List.of(), List.of(), Map.of(), true, CONFIG_MAP_NAME, NAMESPACE,
+ false, true, FAIL_FAST, RetryProperties.DEFAULT);
+ }
+
+ @Bean
+ @Primary
+ KubernetesNamespaceProvider namespaceProvider(AbstractEnvironment environment) {
+ return new KubernetesNamespaceProvider(environment);
+ }
+
+ @Bean
+ @Primary
+ ConfigurationUpdateStrategy configurationUpdateStrategy() {
+ return new ConfigurationUpdateStrategy("to-console", () -> {
+ strategyCalled[0] = true;
+ });
+ }
+
+ @Bean
+ @Primary
+ KubernetesClientConfigMapPropertySourceLocator kubernetesClientConfigMapPropertySourceLocator(
+ ConfigMapConfigProperties configMapConfigProperties, KubernetesNamespaceProvider namespaceProvider) {
+ return new KubernetesClientConfigMapPropertySourceLocator(coreV1Api, configMapConfigProperties,
+ namespaceProvider);
+ }
+
+ }
+
+}
diff --git a/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/PollingReloadSecretTest.java b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/PollingReloadSecretTest.java
new file mode 100644
index 0000000000..4173ac8da2
--- /dev/null
+++ b/spring-cloud-kubernetes-client-config/src/test/java/org/springframework/cloud/kubernetes/client/config/reload_it/PollingReloadSecretTest.java
@@ -0,0 +1,240 @@
+/*
+ * Copyright 2013-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.kubernetes.client.config.reload_it;
+
+import java.time.Duration;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.client.WireMock;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.Configuration;
+import io.kubernetes.client.openapi.JSON;
+import io.kubernetes.client.openapi.apis.CoreV1Api;
+import io.kubernetes.client.openapi.models.V1Secret;
+import io.kubernetes.client.openapi.models.V1SecretBuilder;
+import io.kubernetes.client.openapi.models.V1SecretList;
+import io.kubernetes.client.util.ClientBuilder;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.cloud.kubernetes.client.config.KubernetesClientSecretsPropertySource;
+import org.springframework.cloud.kubernetes.client.config.KubernetesClientSecretsPropertySourceLocator;
+import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider;
+import org.springframework.cloud.kubernetes.commons.config.RetryProperties;
+import org.springframework.cloud.kubernetes.commons.config.SecretsConfigProperties;
+import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties;
+import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy;
+import org.springframework.cloud.kubernetes.commons.config.reload.PollingSecretsChangeDetector;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Primary;
+import org.springframework.core.env.AbstractEnvironment;
+import org.springframework.core.env.PropertySource;
+import org.springframework.mock.env.MockEnvironment;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
+
+/**
+ * @author wind57
+ */
+@SpringBootTest(
+ properties = { "spring.main.allow-bean-definition-overriding=true",
+ "logging.level.org.springframework.cloud.kubernetes.commons.config=debug" },
+ classes = { PollingReloadSecretTest.TestConfig.class })
+@ExtendWith(OutputCaptureExtension.class)
+class PollingReloadSecretTest {
+
+ private static WireMockServer wireMockServer;
+
+ private static final boolean FAIL_FAST = false;
+
+ private static final String SECRET_NAME = "mine";
+
+ private static final String NAMESPACE = "spring-k8s";
+
+ private static final boolean[] strategyCalled = new boolean[] { false };
+
+ private static CoreV1Api coreV1Api;
+
+ @BeforeAll
+ static void setup() {
+ wireMockServer = new WireMockServer(options().dynamicPort());
+
+ wireMockServer.start();
+ WireMock.configureFor("localhost", wireMockServer.port());
+
+ ApiClient client = new ClientBuilder().setBasePath("http://localhost:" + wireMockServer.port()).build();
+ client.setDebugging(true);
+ Configuration.setDefaultApiClient(client);
+ coreV1Api = new CoreV1Api();
+
+ String path = "/api/v1/namespaces/spring-k8s/secrets";
+ V1Secret secretOne = secret(SECRET_NAME, Map.of());
+ V1SecretList listOne = new V1SecretList().addItemsItem(secretOne);
+
+ // needed so that our environment is populated with 'something'
+ // this call is done in the method that returns the AbstractEnvironment
+ stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(listOne)))
+ .inScenario("my-test")
+ .willSetStateTo("go-to-fail"));
+
+ // first reload call fails
+ stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error"))
+ .inScenario("my-test")
+ .whenScenarioStateIs("go-to-fail")
+ .willSetStateTo("go-to-ok"));
+
+ V1Secret secretTwo = secret(SECRET_NAME, Map.of("a", "b"));
+ V1SecretList listTwo = new V1SecretList().addItemsItem(secretTwo);
+ stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(listTwo)))
+ .inScenario("my-test")
+ .whenScenarioStateIs("go-to-ok"));
+
+ }
+
+ @AfterAll
+ static void after() {
+ wireMockServer.stop();
+ }
+
+ /**
+ *
+ * - we have a PropertySource in the environment
+ * - first polling cycle tries to read the sources from k8s and fails
+ * - second polling cycle reads sources from k8s and finds a change
+ *
+ */
+ @Test
+ void test(CapturedOutput output) {
+ // we fail while reading 'secretOne'
+ Awaitility.await().atMost(Duration.ofSeconds(10)).pollInterval(Duration.ofSeconds(1)).until(() -> {
+ boolean one = output.getOut().contains("failure in reading named sources");
+ boolean two = output.getOut()
+ .contains("there was an error while reading config maps/secrets, no reload will happen");
+ boolean three = output.getOut()
+ .contains("reloadable condition was not satisfied, reload will not be triggered");
+ boolean updateStrategyNotCalled = !strategyCalled[0];
+ return one && two && three && updateStrategyNotCalled;
+ });
+
+ // it passes while reading 'secretTwo'
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(10))
+ .pollInterval(Duration.ofSeconds(1))
+ .until(() -> strategyCalled[0]);
+ }
+
+ private static V1Secret secret(String name, Map data) {
+
+ Map encoded = data.entrySet()
+ .stream()
+ .collect(Collectors.toMap(Map.Entry::getKey, e -> Base64.getEncoder().encode(e.getValue().getBytes())));
+
+ return new V1SecretBuilder().withNewMetadata().withName(name).endMetadata().withData(encoded).build();
+ }
+
+ @TestConfiguration
+ static class TestConfig {
+
+ @Bean
+ @Primary
+ PollingSecretsChangeDetector pollingSecretsChangeDetector(AbstractEnvironment environment,
+ ConfigReloadProperties configReloadProperties, ConfigurationUpdateStrategy configurationUpdateStrategy,
+ KubernetesClientSecretsPropertySourceLocator kubernetesClientSecretsPropertySourceLocator) {
+ ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
+ scheduler.initialize();
+ return new PollingSecretsChangeDetector(environment, configReloadProperties, configurationUpdateStrategy,
+ KubernetesClientSecretsPropertySource.class, kubernetesClientSecretsPropertySourceLocator,
+ scheduler);
+ }
+
+ @Bean
+ @Primary
+ AbstractEnvironment environment() {
+ MockEnvironment mockEnvironment = new MockEnvironment();
+ mockEnvironment.setProperty("spring.cloud.kubernetes.client.namespace", NAMESPACE);
+
+ // simulate that environment already has a
+ // KubernetesClientSecretPropertySource,
+ // otherwise we can't properly test reload functionality
+ SecretsConfigProperties secretsConfigProperties = new SecretsConfigProperties(true, Map.of(), List.of(),
+ List.of(), true, SECRET_NAME, NAMESPACE, false, true, false, RetryProperties.DEFAULT);
+ KubernetesNamespaceProvider namespaceProvider = new KubernetesNamespaceProvider(mockEnvironment);
+
+ PropertySource> propertySource = new KubernetesClientSecretsPropertySourceLocator(coreV1Api,
+ namespaceProvider, secretsConfigProperties)
+ .locate(mockEnvironment);
+
+ mockEnvironment.getPropertySources().addFirst(propertySource);
+ return mockEnvironment;
+ }
+
+ @Bean
+ @Primary
+ ConfigReloadProperties configReloadProperties() {
+ return new ConfigReloadProperties(true, false, true, ConfigReloadProperties.ReloadStrategy.REFRESH,
+ ConfigReloadProperties.ReloadDetectionMode.POLLING, Duration.ofMillis(2000), Set.of("non-default"),
+ false, Duration.ofSeconds(2));
+ }
+
+ @Bean
+ @Primary
+ SecretsConfigProperties secretsConfigProperties() {
+ return new SecretsConfigProperties(true, Map.of(), List.of(), List.of(), true, SECRET_NAME, NAMESPACE,
+ false, true, FAIL_FAST, RetryProperties.DEFAULT);
+ }
+
+ @Bean
+ @Primary
+ KubernetesNamespaceProvider namespaceProvider(AbstractEnvironment environment) {
+ return new KubernetesNamespaceProvider(environment);
+ }
+
+ @Bean
+ @Primary
+ ConfigurationUpdateStrategy configurationUpdateStrategy() {
+ return new ConfigurationUpdateStrategy("to-console", () -> {
+ strategyCalled[0] = true;
+ });
+ }
+
+ @Bean
+ @Primary
+ KubernetesClientSecretsPropertySourceLocator kubernetesClientSecretsPropertySourceLocator(
+ SecretsConfigProperties secretsConfigProperties, KubernetesNamespaceProvider namespaceProvider) {
+ return new KubernetesClientSecretsPropertySourceLocator(coreV1Api, namespaceProvider,
+ secretsConfigProperties);
+ }
+
+ }
+
+}
diff --git a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/Constants.java b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/Constants.java
index e8be954fb8..072770f1dd 100644
--- a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/Constants.java
+++ b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/Constants.java
@@ -63,6 +63,12 @@ public final class Constants {
*/
public static final String RELOAD_MODE = "spring.cloud.kubernetes.reload.mode";
+ /**
+ * property set to true when there was an error reading config maps or secrets, when
+ * generating a property source.
+ */
+ public static final String ERROR_PROPERTY = "spring.cloud.k8s.error.reading.property.source";
+
private Constants() {
}
diff --git a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/LabeledSourceData.java b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/LabeledSourceData.java
index 7c3068995c..61178346d9 100644
--- a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/LabeledSourceData.java
+++ b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/LabeledSourceData.java
@@ -21,7 +21,11 @@
import java.util.Set;
import java.util.stream.Collectors;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
import static org.springframework.cloud.kubernetes.commons.config.ConfigUtils.onException;
+import static org.springframework.cloud.kubernetes.commons.config.Constants.ERROR_PROPERTY;
import static org.springframework.cloud.kubernetes.commons.config.Constants.PROPERTY_SOURCE_NAME_SEPARATOR;
/**
@@ -32,6 +36,8 @@
*/
public abstract class LabeledSourceData {
+ private static final Log LOG = LogFactory.getLog(LabeledSourceData.class);
+
public final SourceData compute(Map labels, ConfigUtils.Prefix prefix, String target,
boolean profileSources, boolean failFast, String namespace, String[] activeProfiles) {
@@ -73,7 +79,9 @@ public final SourceData compute(Map labels, ConfigUtils.Prefix p
}
}
catch (Exception e) {
+ LOG.warn("failure in reading labeled sources");
onException(failFast, e);
+ data = new MultipleSourcesContainer(data.names(), Map.of(ERROR_PROPERTY, "true"));
}
String names = data.names().stream().sorted().collect(Collectors.joining(PROPERTY_SOURCE_NAME_SEPARATOR));
diff --git a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/NamedSourceData.java b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/NamedSourceData.java
index c198e43316..acd39b64ba 100644
--- a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/NamedSourceData.java
+++ b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/NamedSourceData.java
@@ -17,12 +17,14 @@
package org.springframework.cloud.kubernetes.commons.config;
import java.util.LinkedHashSet;
+import java.util.Map;
import java.util.stream.Collectors;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import static org.springframework.cloud.kubernetes.commons.config.ConfigUtils.onException;
+import static org.springframework.cloud.kubernetes.commons.config.Constants.ERROR_PROPERTY;
import static org.springframework.cloud.kubernetes.commons.config.Constants.PROPERTY_SOURCE_NAME_SEPARATOR;
/**
@@ -69,7 +71,9 @@ public final SourceData compute(String sourceName, ConfigUtils.Prefix prefix, St
}
catch (Exception e) {
+ LOG.warn("failure in reading named sources");
onException(failFast, e);
+ data = new MultipleSourcesContainer(data.names(), Map.of(ERROR_PROPERTY, "true"));
}
String names = data.names().stream().sorted().collect(Collectors.joining(PROPERTY_SOURCE_NAME_SEPARATOR));
diff --git a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/reload/ConfigReloadUtil.java b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/reload/ConfigReloadUtil.java
index 4279694418..02de15f2c1 100644
--- a/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/reload/ConfigReloadUtil.java
+++ b/spring-cloud-kubernetes-commons/src/main/java/org/springframework/cloud/kubernetes/commons/config/reload/ConfigReloadUtil.java
@@ -33,6 +33,8 @@
import org.springframework.core.env.PropertySource;
import org.springframework.core.log.LogAccessor;
+import static org.springframework.cloud.kubernetes.commons.config.Constants.ERROR_PROPERTY;
+
/**
* @author wind57
*/
@@ -71,11 +73,11 @@ public static boolean reload(PropertySourceLocator locator, ConfigurableEnvironm
boolean changed = changed(sourceFromK8s, existingSources);
if (changed) {
- LOG.info("Detected change in config maps/secrets");
+ LOG.info("Detected change in config maps/secrets, reload will ne triggered");
return true;
}
else {
- LOG.debug("No change detected in config maps/secrets, reload will not happen");
+ LOG.debug("reloadable condition was not satisfied, reload will not be triggered");
}
return false;
@@ -162,6 +164,12 @@ else if (propertySource instanceof CompositePropertySource source) {
}
static boolean changed(List extends MapPropertySource> k8sSources, List extends MapPropertySource> appSources) {
+
+ if (k8sSources.stream().anyMatch(source -> "true".equals(source.getProperty(ERROR_PROPERTY)))) {
+ LOG.info(() -> "there was an error while reading config maps/secrets, no reload will happen");
+ return false;
+ }
+
if (k8sSources.size() != appSources.size()) {
if (LOG.isDebugEnabled()) {
LOG.debug("k8s property sources size: " + k8sSources.size());
diff --git a/spring-cloud-kubernetes-commons/src/test/java/org/springframework/cloud/kubernetes/commons/config/reload/ConfigReloadUtilTests.java b/spring-cloud-kubernetes-commons/src/test/java/org/springframework/cloud/kubernetes/commons/config/reload/ConfigReloadUtilTests.java
index 280d95f9d5..df1f54b2b2 100644
--- a/spring-cloud-kubernetes-commons/src/test/java/org/springframework/cloud/kubernetes/commons/config/reload/ConfigReloadUtilTests.java
+++ b/spring-cloud-kubernetes-commons/src/test/java/org/springframework/cloud/kubernetes/commons/config/reload/ConfigReloadUtilTests.java
@@ -26,6 +26,7 @@
import org.junit.jupiter.api.Test;
import org.springframework.cloud.bootstrap.config.BootstrapPropertySource;
+import org.springframework.cloud.kubernetes.commons.config.Constants;
import org.springframework.cloud.kubernetes.commons.config.MountConfigMapPropertySource;
import org.springframework.core.env.CompositePropertySource;
import org.springframework.core.env.EnumerablePropertySource;
@@ -150,6 +151,16 @@ public Object getProperty(String name) {
Assertions.assertEquals("from-inner-two-composite", result.get(3).getProperty(""));
}
+ @Test
+ void testEmptySourceNameOnError() {
+ Object value = new Object();
+ Map rightMap = Map.of("key", value);
+ MapPropertySource left = new MapPropertySource("on-error", Map.of(Constants.ERROR_PROPERTY, "true"));
+ MapPropertySource right = new MapPropertySource("right", rightMap);
+ boolean changed = ConfigReloadUtil.changed(List.of(left), List.of(right));
+ assertThat(changed).isFalse();
+ }
+
private static final class OneComposite extends CompositePropertySource {
private OneComposite() {
diff --git a/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/Fabric8ConfigMapErrorOnReadingSourceTests.java b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/Fabric8ConfigMapErrorOnReadingSourceTests.java
new file mode 100644
index 0000000000..549cb4dd5f
--- /dev/null
+++ b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/Fabric8ConfigMapErrorOnReadingSourceTests.java
@@ -0,0 +1,291 @@
+/*
+ * Copyright 2013-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.kubernetes.fabric8.config;
+
+import java.util.List;
+import java.util.Map;
+
+import io.fabric8.kubernetes.api.model.ConfigMap;
+import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
+import io.fabric8.kubernetes.api.model.ConfigMapListBuilder;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient;
+import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider;
+import org.springframework.cloud.kubernetes.commons.config.ConfigMapConfigProperties;
+import org.springframework.cloud.kubernetes.commons.config.Constants;
+import org.springframework.cloud.kubernetes.commons.config.RetryProperties;
+import org.springframework.core.env.CompositePropertySource;
+import org.springframework.core.env.MapPropertySource;
+import org.springframework.core.env.PropertySource;
+import org.springframework.mock.env.MockEnvironment;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.cloud.kubernetes.commons.config.ConfigMapConfigProperties.Source;
+
+/**
+ * @author wind57
+ */
+@EnableKubernetesMockClient
+@ExtendWith(OutputCaptureExtension.class)
+class Fabric8ConfigMapErrorOnReadingSourceTests {
+
+ private static KubernetesMockServer mockServer;
+
+ private static KubernetesClient mockClient;
+
+ @BeforeAll
+ static void beforeAll() {
+ mockClient.getConfiguration().setRequestRetryBackoffLimit(0);
+ }
+
+ /**
+ *
+ * we try to read all config maps in a namespace and fail,
+ * thus generate a well defined name for the source.
+ *
+ */
+ @Test
+ void namedSingleConfigMapFails() {
+ String name = "my-config";
+ String namespace = "spring-k8s";
+ String path = "/api/v1/namespaces/" + namespace + "/configmaps";
+
+ mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").once();
+
+ ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(), List.of(),
+ Map.of(), true, name, namespace, false, true, false, RetryProperties.DEFAULT);
+
+ Fabric8ConfigMapPropertySourceLocator locator = new Fabric8ConfigMapPropertySourceLocator(mockClient,
+ configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment()));
+
+ CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment());
+ MapPropertySource mapPropertySource = (MapPropertySource) propertySource.getPropertySources()
+ .stream()
+ .findAny()
+ .orElseThrow();
+
+ assertThat(mapPropertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true");
+
+ }
+
+ /**
+ *
+ * there are two sources and we try to read them.
+ * one fails and one passes.
+ *
+ */
+ @Test
+ void namedTwoConfigMapsOneFails() {
+ String configMapNameOne = "one";
+ String configMapNameTwo = "two";
+ String namespace = "default";
+ String path = "/api/v1/namespaces/default/configmaps";
+
+ ConfigMap configMapTwo = configMap(configMapNameTwo, Map.of());
+
+ mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").once();
+ mockServer.expect()
+ .withPath(path)
+ .andReturn(200, new ConfigMapListBuilder().withItems(configMapTwo).build())
+ .once();
+
+ Source sourceOne = new Source(configMapNameOne, namespace, Map.of(), null, null, null);
+ Source sourceTwo = new Source(configMapNameTwo, namespace, Map.of(), null, null, null);
+
+ ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(),
+ List.of(sourceOne, sourceTwo), Map.of(), true, null, namespace, false, true, false,
+ RetryProperties.DEFAULT);
+
+ Fabric8ConfigMapPropertySourceLocator locator = new Fabric8ConfigMapPropertySourceLocator(mockClient,
+ configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment()));
+
+ CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment());
+ List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList();
+
+ // two sources are present
+ assertThat(names).containsExactly("configmap.two.default", "configmap..default");
+ assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true");
+
+ }
+
+ /**
+ *
+ * there are two sources and we try to read them.
+ * both fail.
+ *
+ */
+ @Test
+ void namedTwoConfigMapsBothFail() {
+ String configMapNameOne = "one";
+ String configMapNameTwo = "two";
+ String namespace = "default";
+ String path = "/api/v1/namespaces/default/configmaps";
+
+ mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").once();
+ mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").once();
+
+ Source sourceOne = new Source(configMapNameOne, namespace, Map.of(), null, null, null);
+ Source sourceTwo = new Source(configMapNameTwo, namespace, Map.of(), null, null, null);
+
+ ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(),
+ List.of(sourceOne, sourceTwo), Map.of(), true, null, namespace, false, true, false,
+ RetryProperties.DEFAULT);
+
+ Fabric8ConfigMapPropertySourceLocator locator = new Fabric8ConfigMapPropertySourceLocator(mockClient,
+ configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment()));
+
+ CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment());
+ List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList();
+
+ assertThat(names).containsExactly("configmap..default");
+ assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true");
+
+ }
+
+ /**
+ *
+ * we try to read all config maps in a namespace and fail,
+ * thus generate a well defined name for the source.
+ *
+ */
+ @Test
+ void labeledSingleConfigMapFails(CapturedOutput output) {
+ Map labels = Map.of("a", "b");
+ String namespace = "spring-k8s";
+ String path = "/api/v1/namespaces/" + namespace + "/configmaps";
+
+ // one for the 'application' named configmap
+ // the other for the labeled config map
+ mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").times(2);
+
+ Source configMapSource = new Source(null, namespace, labels, null, null, null);
+
+ ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(),
+ List.of(configMapSource), labels, true, null, namespace, false, true, false, RetryProperties.DEFAULT);
+
+ Fabric8ConfigMapPropertySourceLocator locator = new Fabric8ConfigMapPropertySourceLocator(mockClient,
+ configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment()));
+
+ CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment());
+ List sourceNames = propertySource.getPropertySources().stream().map(PropertySource::getName).toList();
+
+ assertThat(sourceNames).containsExactly("configmap..spring-k8s");
+ assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true");
+ assertThat(output).contains("failure in reading labeled sources");
+ assertThat(output).contains("failure in reading named sources");
+ }
+
+ /**
+ *
+ * there are two sources and we try to read them.
+ * one fails and one passes.
+ *
+ */
+ @Test
+ void labeledTwoConfigMapsOneFails(CapturedOutput output) {
+ String configMapNameOne = "one";
+ String configMapNameTwo = "two";
+
+ Map configMapOneLabels = Map.of("one", "1");
+ Map configMapTwoLabels = Map.of("two", "2");
+
+ String namespace = "default";
+ String path = "/api/v1/namespaces/default/configmaps";
+
+ ConfigMap configMapOne = configMap(configMapNameOne, configMapOneLabels);
+ ConfigMap configMapTwo = configMap(configMapNameTwo, configMapTwoLabels);
+
+ // one for 'application' named configmap and one for the first labeled configmap
+ mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").times(2);
+ mockServer.expect()
+ .withPath(path)
+ .andReturn(200, new ConfigMapListBuilder().withItems(configMapOne, configMapTwo).build())
+ .once();
+
+ Source sourceOne = new Source(null, namespace, configMapOneLabels, null, null, null);
+ Source sourceTwo = new Source(null, namespace, configMapTwoLabels, null, null, null);
+
+ ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(),
+ List.of(sourceOne, sourceTwo), Map.of("one", "1", "two", "2"), true, null, namespace, false, true,
+ false, RetryProperties.DEFAULT);
+
+ Fabric8ConfigMapPropertySourceLocator locator = new Fabric8ConfigMapPropertySourceLocator(mockClient,
+ configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment()));
+
+ CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment());
+ List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList();
+
+ // two sources are present, one being empty
+ assertThat(names).containsExactly("configmap.two.default", "configmap..default");
+ assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true");
+
+ assertThat(output).contains("failure in reading labeled sources");
+ assertThat(output).contains("failure in reading named sources");
+
+ }
+
+ /**
+ *
+ * there are two sources and we try to read them.
+ * both fail.
+ *
+ */
+ @Test
+ void labeledTwoConfigMapsBothFail(CapturedOutput output) {
+
+ Map configMapOneLabels = Map.of("one", "1");
+ Map configMapTwoLabels = Map.of("two", "2");
+
+ String namespace = "default";
+ String path = "/api/v1/namespaces/default/configmaps";
+
+ // one for 'application' named configmap and two for the labeled configmaps
+ mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").times(3);
+
+ Source sourceOne = new Source(null, namespace, configMapOneLabels, null, null, null);
+ Source sourceTwo = new Source(null, namespace, configMapTwoLabels, null, null, null);
+
+ ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(),
+ List.of(sourceOne, sourceTwo), Map.of("one", "1", "two", "2"), true, null, namespace, false, true,
+ false, RetryProperties.DEFAULT);
+
+ Fabric8ConfigMapPropertySourceLocator locator = new Fabric8ConfigMapPropertySourceLocator(mockClient,
+ configMapConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment()));
+
+ CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment());
+ List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList();
+
+ assertThat(names).containsExactly("configmap..default");
+ assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true");
+
+ assertThat(output).contains("failure in reading labeled sources");
+ assertThat(output).contains("failure in reading named sources");
+
+ }
+
+ private ConfigMap configMap(String name, Map labels) {
+ return new ConfigMapBuilder().withNewMetadata().withName(name).withLabels(labels).endMetadata().build();
+ }
+
+}
diff --git a/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/Fabric8SecretErrorOnReadingSourceTests.java b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/Fabric8SecretErrorOnReadingSourceTests.java
new file mode 100644
index 0000000000..4c203861f3
--- /dev/null
+++ b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/Fabric8SecretErrorOnReadingSourceTests.java
@@ -0,0 +1,288 @@
+/*
+ * Copyright 2013-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.kubernetes.fabric8.config;
+
+import java.util.List;
+import java.util.Map;
+
+import io.fabric8.kubernetes.api.model.Secret;
+import io.fabric8.kubernetes.api.model.SecretBuilder;
+import io.fabric8.kubernetes.api.model.SecretListBuilder;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient;
+import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider;
+import org.springframework.cloud.kubernetes.commons.config.Constants;
+import org.springframework.cloud.kubernetes.commons.config.RetryProperties;
+import org.springframework.cloud.kubernetes.commons.config.SecretsConfigProperties;
+import org.springframework.core.env.CompositePropertySource;
+import org.springframework.core.env.MapPropertySource;
+import org.springframework.core.env.PropertySource;
+import org.springframework.mock.env.MockEnvironment;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.cloud.kubernetes.commons.config.SecretsConfigProperties.Source;
+
+/**
+ * @author wind57
+ */
+@EnableKubernetesMockClient
+@ExtendWith(OutputCaptureExtension.class)
+class Fabric8SecretErrorOnReadingSourceTests {
+
+ private static KubernetesMockServer mockServer;
+
+ private static KubernetesClient mockClient;
+
+ @BeforeAll
+ static void beforeAll() {
+ mockClient.getConfiguration().setRequestRetryBackoffLimit(0);
+ }
+
+ /**
+ *
+ * we try to read all secrets in a namespace and fail,
+ * thus generate a well defined name for the source.
+ *
+ */
+ @Test
+ void namedSingleSecretFails(CapturedOutput output) {
+ String name = "my-secret";
+ String namespace = "spring-k8s";
+ String path = "/api/v1/namespaces/" + namespace + "/secrets";
+
+ mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").once();
+
+ SecretsConfigProperties secretsConfigProperties = new SecretsConfigProperties(true, Map.of(), List.of(),
+ List.of(), true, name, namespace, false, true, false, RetryProperties.DEFAULT);
+
+ Fabric8SecretsPropertySourceLocator locator = new Fabric8SecretsPropertySourceLocator(mockClient,
+ secretsConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment()));
+
+ CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment());
+ MapPropertySource mapPropertySource = (MapPropertySource) propertySource.getPropertySources()
+ .stream()
+ .findAny()
+ .orElseThrow();
+
+ assertThat(mapPropertySource.getName()).isEqualTo("secret..spring-k8s");
+ assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true");
+ assertThat(output).contains("failure in reading named sources");
+
+ }
+
+ /**
+ *
+ * there are two sources and we try to read them.
+ * one fails and one passes.
+ *
+ */
+ @Test
+ void namedTwoSecretsOneFails() {
+ String secretNameOne = "one";
+ String secretNameTwo = "two";
+ String namespace = "default";
+ String path = "/api/v1/namespaces/default/secrets";
+
+ Secret secretTwo = secret(secretNameTwo, Map.of());
+
+ mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").once();
+ mockServer.expect().withPath(path).andReturn(200, new SecretListBuilder().withItems(secretTwo).build()).once();
+
+ Source sourceOne = new Source(secretNameOne, namespace, Map.of(), null, null, null);
+ Source sourceTwo = new Source(secretNameTwo, namespace, Map.of(), null, null, null);
+
+ SecretsConfigProperties secretsConfigProperties = new SecretsConfigProperties(true, Map.of(), List.of(),
+ List.of(sourceOne, sourceTwo), true, null, namespace, false, true, false, RetryProperties.DEFAULT);
+
+ Fabric8SecretsPropertySourceLocator locator = new Fabric8SecretsPropertySourceLocator(mockClient,
+ secretsConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment()));
+
+ CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment());
+ List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList();
+
+ // two sources are present, one being empty
+ assertThat(names).containsExactly("secret.two.default", "secret..default");
+ assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true");
+
+ }
+
+ /**
+ *
+ * there are two sources and we try to read them.
+ * both fail.
+ *
+ */
+ @Test
+ void namedTwoSecretsBothFail() {
+ String secretNameOne = "one";
+ String secretNameTwo = "two";
+ String namespace = "default";
+ String path = "/api/v1/namespaces/default/secrets";
+
+ mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").once();
+ mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").once();
+
+ Source sourceOne = new Source(secretNameOne, namespace, Map.of(), null, null, null);
+ Source sourceTwo = new Source(secretNameTwo, namespace, Map.of(), null, null, null);
+
+ SecretsConfigProperties secretsConfigProperties = new SecretsConfigProperties(true, Map.of(), List.of(),
+ List.of(sourceOne, sourceTwo), true, null, namespace, false, true, false, RetryProperties.DEFAULT);
+
+ Fabric8SecretsPropertySourceLocator locator = new Fabric8SecretsPropertySourceLocator(mockClient,
+ secretsConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment()));
+
+ CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment());
+ List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList();
+
+ assertThat(names).containsExactly("secret..default");
+ assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true");
+
+ }
+
+ /**
+ *
+ * we try to read all secrets in a namespace and fail,
+ * thus generate a well defined name for the source.
+ *
+ */
+ @Test
+ void labeledSingleSecretFails(CapturedOutput output) {
+ Map labels = Map.of("a", "b");
+ String namespace = "spring-k8s";
+ String path = "/api/v1/namespaces/" + namespace + "/secrets";
+
+ // one for the 'application' named secret
+ // the other for the labeled secret
+ mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").times(2);
+
+ Source secretSource = new Source(null, namespace, labels, null, null, null);
+
+ SecretsConfigProperties secretsConfigProperties = new SecretsConfigProperties(true, labels, List.of(),
+ List.of(secretSource), true, null, namespace, false, true, false, RetryProperties.DEFAULT);
+
+ Fabric8SecretsPropertySourceLocator locator = new Fabric8SecretsPropertySourceLocator(mockClient,
+ secretsConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment()));
+
+ CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment());
+ List sourceNames = propertySource.getPropertySources().stream().map(PropertySource::getName).toList();
+
+ assertThat(sourceNames).containsExactly("secret..spring-k8s");
+ assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true");
+ assertThat(output).contains("failure in reading labeled sources");
+ assertThat(output).contains("failure in reading named sources");
+ }
+
+ /**
+ *
+ * there are two sources and we try to read them.
+ * one fails and one passes.
+ *
+ */
+ @Test
+ void labeledTwoSecretsOneFails(CapturedOutput output) {
+ String secretNameOne = "one";
+ String secretNameTwo = "two";
+
+ Map secretOneLabels = Map.of("one", "1");
+ Map secretTwoLabels = Map.of("two", "2");
+
+ String namespace = "default";
+ String path = "/api/v1/namespaces/default/secrets";
+
+ Secret secretOne = secret(secretNameOne, secretOneLabels);
+ Secret secretTwo = secret(secretNameTwo, secretTwoLabels);
+
+ // one for 'application' named secret and one for the first labeled secret
+ mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").times(2);
+ mockServer.expect()
+ .withPath(path)
+ .andReturn(200, new SecretListBuilder().withItems(secretOne, secretTwo).build())
+ .once();
+
+ Source sourceOne = new Source(null, namespace, secretOneLabels, null, null, null);
+ Source sourceTwo = new Source(null, namespace, secretTwoLabels, null, null, null);
+
+ SecretsConfigProperties secretsConfigProperties = new SecretsConfigProperties(true,
+ Map.of("one", "1", "two", "2"), List.of(), List.of(sourceOne, sourceTwo), true, null, namespace, false,
+ true, false, RetryProperties.DEFAULT);
+
+ Fabric8SecretsPropertySourceLocator locator = new Fabric8SecretsPropertySourceLocator(mockClient,
+ secretsConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment()));
+
+ CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment());
+ List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList();
+
+ // two sources are present, one being empty
+ assertThat(names).containsExactly("secret.two.default", "secret..default");
+ assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true");
+
+ assertThat(output).contains("failure in reading labeled sources");
+ assertThat(output).contains("failure in reading named sources");
+
+ }
+
+ /**
+ *
+ * there are two sources and we try to read them.
+ * both fail.
+ *
+ */
+ @Test
+ void labeledTwoConfigMapsBothFail(CapturedOutput output) {
+
+ Map secretOneLabels = Map.of("one", "1");
+ Map secretTwoLabels = Map.of("two", "2");
+
+ String namespace = "default";
+ String path = "/api/v1/namespaces/default/secrets";
+
+ // one for 'application' named configmap and two for the labeled configmaps
+ mockServer.expect().withPath(path).andReturn(500, "Internal Server Error").times(3);
+
+ Source sourceOne = new Source(null, namespace, secretOneLabels, null, null, null);
+ Source sourceTwo = new Source(null, namespace, secretTwoLabels, null, null, null);
+
+ SecretsConfigProperties secretsConfigProperties = new SecretsConfigProperties(true,
+ Map.of("one", "1", "two", "2"), List.of(), List.of(sourceOne, sourceTwo), true, null, namespace, false,
+ true, false, RetryProperties.DEFAULT);
+
+ Fabric8SecretsPropertySourceLocator locator = new Fabric8SecretsPropertySourceLocator(mockClient,
+ secretsConfigProperties, new KubernetesNamespaceProvider(new MockEnvironment()));
+
+ CompositePropertySource propertySource = (CompositePropertySource) locator.locate(new MockEnvironment());
+ List names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList();
+
+ assertThat(names).containsExactly("secret..default");
+ assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true");
+
+ assertThat(output).contains("failure in reading labeled sources");
+ assertThat(output).contains("failure in reading named sources");
+
+ }
+
+ private Secret secret(String name, Map labels) {
+ return new SecretBuilder().withNewMetadata().withName(name).withLabels(labels).endMetadata().build();
+ }
+
+}
diff --git a/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/VisibleFabric8ConfigMapPropertySourceLocator.java b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/VisibleFabric8ConfigMapPropertySourceLocator.java
new file mode 100644
index 0000000000..7dfdfa566f
--- /dev/null
+++ b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/VisibleFabric8ConfigMapPropertySourceLocator.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2013-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.kubernetes.fabric8.config;
+
+import io.fabric8.kubernetes.client.KubernetesClient;
+
+import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider;
+import org.springframework.cloud.kubernetes.commons.config.ConfigMapConfigProperties;
+
+/**
+ * Only needed to make Fabric8ConfigMapPropertySourceLocator visible for testing purposes
+ *
+ * @author wind57
+ */
+public class VisibleFabric8ConfigMapPropertySourceLocator extends Fabric8ConfigMapPropertySourceLocator {
+
+ public VisibleFabric8ConfigMapPropertySourceLocator(KubernetesClient client, ConfigMapConfigProperties properties,
+ KubernetesNamespaceProvider provider) {
+ super(client, properties, provider);
+ }
+
+}
diff --git a/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/VisibleFabric8SecretsPropertySourceLocator.java b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/VisibleFabric8SecretsPropertySourceLocator.java
new file mode 100644
index 0000000000..d0278e4d1f
--- /dev/null
+++ b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/VisibleFabric8SecretsPropertySourceLocator.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2013-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.kubernetes.fabric8.config;
+
+import io.fabric8.kubernetes.client.KubernetesClient;
+
+import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider;
+import org.springframework.cloud.kubernetes.commons.config.SecretsConfigProperties;
+
+/**
+ * Only needed to make Fabric8SecretsPropertySourceLocator visible for testing purposes
+ *
+ * @author wind57
+ */
+public class VisibleFabric8SecretsPropertySourceLocator extends Fabric8SecretsPropertySourceLocator {
+
+ public VisibleFabric8SecretsPropertySourceLocator(KubernetesClient client, SecretsConfigProperties properties,
+ KubernetesNamespaceProvider provider) {
+ super(client, properties, provider);
+ }
+
+}
diff --git a/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/EventReloadConfigMapTest.java b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/EventReloadConfigMapTest.java
new file mode 100644
index 0000000000..d14efbabab
--- /dev/null
+++ b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/EventReloadConfigMapTest.java
@@ -0,0 +1,215 @@
+/*
+ * Copyright 2012-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.kubernetes.fabric8.config.reload_it;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import io.fabric8.kubernetes.api.model.ConfigMap;
+import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
+import io.fabric8.kubernetes.api.model.ConfigMapList;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.dsl.MixedOperation;
+import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation;
+import io.fabric8.kubernetes.client.dsl.Resource;
+import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mockito;
+
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider;
+import org.springframework.cloud.kubernetes.commons.config.ConfigMapConfigProperties;
+import org.springframework.cloud.kubernetes.commons.config.RetryProperties;
+import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties;
+import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy;
+import org.springframework.cloud.kubernetes.fabric8.config.Fabric8ConfigMapPropertySourceLocator;
+import org.springframework.cloud.kubernetes.fabric8.config.VisibleFabric8ConfigMapPropertySourceLocator;
+import org.springframework.cloud.kubernetes.fabric8.config.reload.Fabric8EventBasedConfigMapChangeDetector;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Primary;
+import org.springframework.core.env.AbstractEnvironment;
+import org.springframework.core.env.PropertySource;
+import org.springframework.mock.env.MockEnvironment;
+
+/**
+ * @author wind57
+ */
+@SpringBootTest(
+ properties = { "spring.main.allow-bean-definition-overriding=true",
+ "logging.level.org.springframework.cloud.kubernetes.commons.config=debug" },
+ classes = { EventReloadConfigMapTest.TestConfig.class })
+@EnableKubernetesMockClient(crud = true)
+@ExtendWith(OutputCaptureExtension.class)
+public class EventReloadConfigMapTest {
+
+ private static final boolean FAIL_FAST = false;
+
+ private static final String CONFIG_MAP_NAME = "mine";
+
+ private static final String NAMESPACE = "spring-k8s";
+
+ private static KubernetesClient kubernetesClient;
+
+ private static final boolean[] strategyCalled = new boolean[] { false };
+
+ @BeforeAll
+ static void beforeAll() {
+
+ kubernetesClient = Mockito.spy(kubernetesClient);
+ kubernetesClient.getConfiguration().setRequestRetryBackoffLimit(0);
+
+ ConfigMap configMapOne = configMap(CONFIG_MAP_NAME, Map.of());
+
+ // for the informer, when it starts
+ kubernetesClient.configMaps().inNamespace(NAMESPACE).resource(configMapOne).create();
+ }
+
+ @Test
+ @SuppressWarnings({ "unchecked" })
+ void test(CapturedOutput output) {
+
+ // we need to create this one before mocking calls
+ NonNamespaceOperation> operation = kubernetesClient.configMaps()
+ .inNamespace(NAMESPACE);
+
+ // makes sure that when 'onEvent' is triggered (because we added a config map)
+ // the call to /api/v1/namespaces/spring-k8s/configmaps will fail with an
+ // Exception
+ MixedOperation> mixedOperation = Mockito
+ .mock(MixedOperation.class);
+ NonNamespaceOperation> mockedOperation = Mockito
+ .mock(NonNamespaceOperation.class);
+ Mockito.when(kubernetesClient.configMaps()).thenReturn(mixedOperation);
+ Mockito.when(mixedOperation.inNamespace(NAMESPACE)).thenReturn(mockedOperation);
+ Mockito.when(mockedOperation.list()).thenThrow(new RuntimeException("failed in reading configmap"));
+
+ // create a different configmap that triggers even based reloading.
+ // the one we create, will trigger a call to
+ // /api/v1/namespaces/spring-k8s/configmaps
+ // that we mocked above to fail.
+ ConfigMap configMapTwo = configMap("not" + CONFIG_MAP_NAME, Map.of("a", "b"));
+ operation.resource(configMapTwo).create();
+
+ // we fail while reading 'configMapTwo'
+ Awaitility.await().atMost(Duration.ofSeconds(10)).pollInterval(Duration.ofSeconds(1)).until(() -> {
+ boolean one = output.getOut().contains("failure in reading named sources");
+ boolean two = output.getOut()
+ .contains("there was an error while reading config maps/secrets, no reload will happen");
+ boolean three = output.getOut()
+ .contains("reloadable condition was not satisfied, reload will not be triggered");
+ boolean updateStrategyNotCalled = !strategyCalled[0];
+ return one && two && three && updateStrategyNotCalled;
+ });
+
+ // reset the mock and replace our configmap with some data, so that reload
+ // is triggered
+ Mockito.reset(kubernetesClient);
+ ConfigMap configMapOne = configMap(CONFIG_MAP_NAME, Map.of("a", "b"));
+ operation.resource(configMapOne).replace();
+
+ // it passes while reading 'configMapThatWillPass'
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(10))
+ .pollInterval(Duration.ofSeconds(1))
+ .until(() -> strategyCalled[0]);
+ }
+
+ private static ConfigMap configMap(String name, Map data) {
+ return new ConfigMapBuilder().withNewMetadata().withName(name).endMetadata().withData(data).build();
+ }
+
+ @TestConfiguration
+ static class TestConfig {
+
+ @Bean
+ @Primary
+ Fabric8EventBasedConfigMapChangeDetector fabric8EventBasedSecretsChangeDetector(AbstractEnvironment environment,
+ ConfigReloadProperties configReloadProperties, ConfigurationUpdateStrategy configurationUpdateStrategy,
+ Fabric8ConfigMapPropertySourceLocator fabric8ConfigMapPropertySourceLocator,
+ KubernetesNamespaceProvider namespaceProvider) {
+ return new Fabric8EventBasedConfigMapChangeDetector(environment, configReloadProperties, kubernetesClient,
+ configurationUpdateStrategy, fabric8ConfigMapPropertySourceLocator, namespaceProvider);
+ }
+
+ @Bean
+ @Primary
+ AbstractEnvironment environment() {
+ MockEnvironment mockEnvironment = new MockEnvironment();
+ mockEnvironment.setProperty("spring.cloud.kubernetes.client.namespace", NAMESPACE);
+
+ // simulate that environment already has a Fabric8ConfigMapPropertySource,
+ // otherwise we can't properly test reload functionality
+ ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(),
+ List.of(), Map.of(), true, CONFIG_MAP_NAME, NAMESPACE, false, true, true, RetryProperties.DEFAULT);
+ KubernetesNamespaceProvider namespaceProvider = new KubernetesNamespaceProvider(mockEnvironment);
+
+ PropertySource> propertySource = new VisibleFabric8ConfigMapPropertySourceLocator(kubernetesClient,
+ configMapConfigProperties, namespaceProvider)
+ .locate(mockEnvironment);
+
+ mockEnvironment.getPropertySources().addFirst(propertySource);
+ return mockEnvironment;
+ }
+
+ @Bean
+ @Primary
+ ConfigReloadProperties configReloadProperties() {
+ return new ConfigReloadProperties(true, true, false, ConfigReloadProperties.ReloadStrategy.REFRESH,
+ ConfigReloadProperties.ReloadDetectionMode.EVENT, Duration.ofMillis(2000), Set.of(NAMESPACE), false,
+ Duration.ofSeconds(2));
+ }
+
+ @Bean
+ @Primary
+ ConfigMapConfigProperties configMapConfigProperties() {
+ return new ConfigMapConfigProperties(true, List.of(), List.of(), Map.of(), true, CONFIG_MAP_NAME, NAMESPACE,
+ false, true, FAIL_FAST, RetryProperties.DEFAULT);
+ }
+
+ @Bean
+ @Primary
+ KubernetesNamespaceProvider namespaceProvider(AbstractEnvironment environment) {
+ return new KubernetesNamespaceProvider(environment);
+ }
+
+ @Bean
+ @Primary
+ ConfigurationUpdateStrategy configurationUpdateStrategy() {
+ return new ConfigurationUpdateStrategy("to-console", () -> {
+ strategyCalled[0] = true;
+ });
+ }
+
+ @Bean
+ @Primary
+ Fabric8ConfigMapPropertySourceLocator fabric8ConfigMapPropertySourceLocator(
+ ConfigMapConfigProperties configMapConfigProperties, KubernetesNamespaceProvider namespaceProvider) {
+ return new VisibleFabric8ConfigMapPropertySourceLocator(kubernetesClient, configMapConfigProperties,
+ namespaceProvider);
+ }
+
+ }
+
+}
diff --git a/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/EventReloadSecretTest.java b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/EventReloadSecretTest.java
new file mode 100644
index 0000000000..546ae964b4
--- /dev/null
+++ b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/EventReloadSecretTest.java
@@ -0,0 +1,222 @@
+/*
+ * Copyright 2012-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.kubernetes.fabric8.config.reload_it;
+
+import java.time.Duration;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.fabric8.kubernetes.api.model.Secret;
+import io.fabric8.kubernetes.api.model.SecretBuilder;
+import io.fabric8.kubernetes.api.model.SecretList;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.dsl.MixedOperation;
+import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation;
+import io.fabric8.kubernetes.client.dsl.Resource;
+import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mockito;
+
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider;
+import org.springframework.cloud.kubernetes.commons.config.RetryProperties;
+import org.springframework.cloud.kubernetes.commons.config.SecretsConfigProperties;
+import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties;
+import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy;
+import org.springframework.cloud.kubernetes.fabric8.config.Fabric8SecretsPropertySourceLocator;
+import org.springframework.cloud.kubernetes.fabric8.config.VisibleFabric8SecretsPropertySourceLocator;
+import org.springframework.cloud.kubernetes.fabric8.config.reload.Fabric8EventBasedSecretsChangeDetector;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Primary;
+import org.springframework.core.env.AbstractEnvironment;
+import org.springframework.core.env.PropertySource;
+import org.springframework.mock.env.MockEnvironment;
+
+/**
+ * @author wind57
+ */
+@SpringBootTest(
+ properties = { "spring.main.allow-bean-definition-overriding=true",
+ "logging.level.org.springframework.cloud.kubernetes.commons.config=debug" },
+ classes = { EventReloadSecretTest.TestConfig.class })
+@EnableKubernetesMockClient(crud = true)
+@ExtendWith(OutputCaptureExtension.class)
+
+class EventReloadSecretTest {
+
+ private static final boolean FAIL_FAST = false;
+
+ private static final String SECRET_NAME = "mine";
+
+ private static final String NAMESPACE = "spring-k8s";
+
+ private static KubernetesClient kubernetesClient;
+
+ private static final boolean[] strategyCalled = new boolean[] { false };
+
+ @BeforeAll
+ static void beforeAll() {
+
+ kubernetesClient = Mockito.spy(kubernetesClient);
+ kubernetesClient.getConfiguration().setRequestRetryBackoffLimit(0);
+
+ Secret secretOne = secret(SECRET_NAME, Map.of());
+
+ // for the informer, when it starts
+ kubernetesClient.secrets().inNamespace(NAMESPACE).resource(secretOne).create();
+ }
+
+ @Test
+ @SuppressWarnings({ "unchecked" })
+ void test(CapturedOutput output) {
+
+ // we need to create this one before mocking calls
+ NonNamespaceOperation> operation = kubernetesClient.secrets()
+ .inNamespace(NAMESPACE);
+
+ // makes sure that when 'onEvent' is triggered (because we added a config map)
+ // the call to /api/v1/namespaces/spring-k8s/secrets will fail with an
+ // Exception
+ MixedOperation> mixedOperation = Mockito.mock(MixedOperation.class);
+ NonNamespaceOperation> mockedOperation = Mockito
+ .mock(NonNamespaceOperation.class);
+ Mockito.when(kubernetesClient.secrets()).thenReturn(mixedOperation);
+ Mockito.when(mixedOperation.inNamespace(NAMESPACE)).thenReturn(mockedOperation);
+ Mockito.when(mockedOperation.list()).thenThrow(new RuntimeException("failed in reading secret"));
+
+ // create a different secret that triggers even based reloading.
+ // the one we create, will trigger a call to
+ // /api/v1/namespaces/spring-k8s/secrets
+ // that we mocked above to fail.
+ Secret secretTwo = secret("not" + SECRET_NAME, Map.of("a", "b"));
+ operation.resource(secretTwo).create();
+
+ // we fail while reading 'secretTwo'
+ Awaitility.await().atMost(Duration.ofSeconds(10)).pollInterval(Duration.ofSeconds(1)).until(() -> {
+ boolean one = output.getOut().contains("failure in reading named sources");
+ boolean two = output.getOut()
+ .contains("there was an error while reading config maps/secrets, no reload will happen");
+ boolean three = output.getOut()
+ .contains("reloadable condition was not satisfied, reload will not be triggered");
+ boolean updateStrategyNotCalled = !strategyCalled[0];
+ return one && two && three && updateStrategyNotCalled;
+ });
+
+ // reset the mock and replace our secret with some data, so that reload
+ // is triggered
+ Mockito.reset(kubernetesClient);
+ Secret secretOne = secret(SECRET_NAME, Map.of("a", "b"));
+ operation.resource(secretOne).replace();
+
+ // it passes while reading 'secretOne'
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(10))
+ .pollInterval(Duration.ofSeconds(1))
+ .until(() -> strategyCalled[0]);
+ }
+
+ private static Secret secret(String name, Map data) {
+ Map encoded = data.entrySet()
+ .stream()
+ .collect(Collectors.toMap(Map.Entry::getKey,
+ e -> new String(Base64.getEncoder().encode(e.getValue().getBytes()))));
+ return new SecretBuilder().withNewMetadata().withName(name).endMetadata().withData(encoded).build();
+ }
+
+ @TestConfiguration
+ static class TestConfig {
+
+ @Bean
+ @Primary
+ Fabric8EventBasedSecretsChangeDetector fabric8EventBasedSecretsChangeDetector(AbstractEnvironment environment,
+ ConfigReloadProperties configReloadProperties, ConfigurationUpdateStrategy configurationUpdateStrategy,
+ Fabric8SecretsPropertySourceLocator fabric8SecretsPropertySourceLocator,
+ KubernetesNamespaceProvider namespaceProvider) {
+ return new Fabric8EventBasedSecretsChangeDetector(environment, configReloadProperties, kubernetesClient,
+ configurationUpdateStrategy, fabric8SecretsPropertySourceLocator, namespaceProvider);
+ }
+
+ @Bean
+ @Primary
+ AbstractEnvironment environment() {
+ MockEnvironment mockEnvironment = new MockEnvironment();
+ mockEnvironment.setProperty("spring.cloud.kubernetes.client.namespace", NAMESPACE);
+
+ // simulate that environment already has a
+ // Fabric8SecretsPropertySourceLocator,
+ // otherwise we can't properly test reload functionality
+ SecretsConfigProperties secretsConfigProperties = new SecretsConfigProperties(true, Map.of(), List.of(),
+ List.of(), true, SECRET_NAME, NAMESPACE, false, true, true, RetryProperties.DEFAULT);
+ KubernetesNamespaceProvider namespaceProvider = new KubernetesNamespaceProvider(mockEnvironment);
+
+ PropertySource> propertySource = new VisibleFabric8SecretsPropertySourceLocator(kubernetesClient,
+ secretsConfigProperties, namespaceProvider)
+ .locate(mockEnvironment);
+
+ mockEnvironment.getPropertySources().addFirst(propertySource);
+ return mockEnvironment;
+ }
+
+ @Bean
+ @Primary
+ ConfigReloadProperties configReloadProperties() {
+ return new ConfigReloadProperties(true, true, false, ConfigReloadProperties.ReloadStrategy.REFRESH,
+ ConfigReloadProperties.ReloadDetectionMode.EVENT, Duration.ofMillis(2000), Set.of(NAMESPACE), false,
+ Duration.ofSeconds(2));
+ }
+
+ @Bean
+ @Primary
+ SecretsConfigProperties secretsConfigProperties() {
+ return new SecretsConfigProperties(true, Map.of(), List.of(), List.of(), true, SECRET_NAME, NAMESPACE,
+ false, true, FAIL_FAST, RetryProperties.DEFAULT);
+ }
+
+ @Bean
+ @Primary
+ KubernetesNamespaceProvider namespaceProvider(AbstractEnvironment environment) {
+ return new KubernetesNamespaceProvider(environment);
+ }
+
+ @Bean
+ @Primary
+ ConfigurationUpdateStrategy configurationUpdateStrategy() {
+ return new ConfigurationUpdateStrategy("to-console", () -> {
+ strategyCalled[0] = true;
+ });
+ }
+
+ @Bean
+ @Primary
+ Fabric8SecretsPropertySourceLocator fabric8SecretsPropertySourceLocator(
+ SecretsConfigProperties secretsConfigProperties, KubernetesNamespaceProvider namespaceProvider) {
+ return new VisibleFabric8SecretsPropertySourceLocator(kubernetesClient, secretsConfigProperties,
+ namespaceProvider);
+ }
+
+ }
+
+}
diff --git a/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/PollingReloadConfigMapTest.java b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/PollingReloadConfigMapTest.java
new file mode 100644
index 0000000000..372ca8be31
--- /dev/null
+++ b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/PollingReloadConfigMapTest.java
@@ -0,0 +1,205 @@
+/*
+ * Copyright 2012-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.kubernetes.fabric8.config.reload_it;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import io.fabric8.kubernetes.api.model.ConfigMap;
+import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
+import io.fabric8.kubernetes.api.model.ConfigMapListBuilder;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient;
+import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider;
+import org.springframework.cloud.kubernetes.commons.config.ConfigMapConfigProperties;
+import org.springframework.cloud.kubernetes.commons.config.RetryProperties;
+import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties;
+import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy;
+import org.springframework.cloud.kubernetes.commons.config.reload.PollingConfigMapChangeDetector;
+import org.springframework.cloud.kubernetes.fabric8.config.Fabric8ConfigMapPropertySource;
+import org.springframework.cloud.kubernetes.fabric8.config.Fabric8ConfigMapPropertySourceLocator;
+import org.springframework.cloud.kubernetes.fabric8.config.VisibleFabric8ConfigMapPropertySourceLocator;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Primary;
+import org.springframework.core.env.AbstractEnvironment;
+import org.springframework.core.env.PropertySource;
+import org.springframework.mock.env.MockEnvironment;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+
+/**
+ * @author wind57
+ */
+@SpringBootTest(
+ properties = { "spring.main.allow-bean-definition-overriding=true",
+ "logging.level.org.springframework.cloud.kubernetes.commons.config=debug" },
+ classes = { PollingReloadConfigMapTest.TestConfig.class })
+@EnableKubernetesMockClient
+@ExtendWith(OutputCaptureExtension.class)
+class PollingReloadConfigMapTest {
+
+ private static final boolean FAIL_FAST = false;
+
+ private static final String CONFIG_MAP_NAME = "mine";
+
+ private static final String NAMESPACE = "spring-k8s";
+
+ private static KubernetesMockServer kubernetesMockServer;
+
+ private static KubernetesClient kubernetesClient;
+
+ private static final boolean[] strategyCalled = new boolean[] { false };
+
+ @BeforeAll
+ static void beforeAll() {
+
+ kubernetesClient.getConfiguration().setRequestRetryBackoffLimit(0);
+
+ // needed so that our environment is populated with 'something'
+ // this call is done in the method that returns the AbstractEnvironment
+ ConfigMap configMapOne = configMap(CONFIG_MAP_NAME, Map.of());
+ ConfigMap configMapTwo = configMap(CONFIG_MAP_NAME, Map.of("a", "b"));
+ String path = "/api/v1/namespaces/spring-k8s/configmaps";
+ kubernetesMockServer.expect()
+ .withPath(path)
+ .andReturn(200, new ConfigMapListBuilder().withItems(configMapOne).build())
+ .once();
+
+ kubernetesMockServer.expect().withPath(path).andReturn(500, "Internal Server Error").once();
+
+ kubernetesMockServer.expect()
+ .withPath(path)
+ .andReturn(200, new ConfigMapListBuilder().withItems(configMapTwo).build())
+ .once();
+ }
+
+ /**
+ *
+ * - we have a PropertySource in the environment
+ * - first polling cycle tries to read the sources from k8s and fails
+ * - second polling cycle reads sources from k8s and finds a change
+ *
+ */
+ @Test
+ void test(CapturedOutput output) {
+ // we fail while reading 'configMapOne'
+ Awaitility.await().atMost(Duration.ofSeconds(10)).pollInterval(Duration.ofSeconds(1)).until(() -> {
+ boolean one = output.getOut().contains("failure in reading named sources");
+ boolean two = output.getOut()
+ .contains("there was an error while reading config maps/secrets, no reload will happen");
+ boolean three = output.getOut()
+ .contains("reloadable condition was not satisfied, reload will not be triggered");
+ boolean updateStrategyNotCalled = !strategyCalled[0];
+ return one && two && three && updateStrategyNotCalled;
+ });
+
+ // it passes while reading 'configMapTwo'
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(10))
+ .pollInterval(Duration.ofSeconds(1))
+ .until(() -> strategyCalled[0]);
+ }
+
+ private static ConfigMap configMap(String name, Map data) {
+ return new ConfigMapBuilder().withNewMetadata().withName(name).endMetadata().withData(data).build();
+ }
+
+ @TestConfiguration
+ static class TestConfig {
+
+ @Bean
+ @Primary
+ PollingConfigMapChangeDetector pollingConfigMapChangeDetector(AbstractEnvironment environment,
+ ConfigReloadProperties configReloadProperties, ConfigurationUpdateStrategy configurationUpdateStrategy,
+ Fabric8ConfigMapPropertySourceLocator fabric8ConfigMapPropertySourceLocator) {
+ ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
+ scheduler.initialize();
+ return new PollingConfigMapChangeDetector(environment, configReloadProperties, configurationUpdateStrategy,
+ Fabric8ConfigMapPropertySource.class, fabric8ConfigMapPropertySourceLocator, scheduler);
+ }
+
+ @Bean
+ @Primary
+ AbstractEnvironment environment() {
+ MockEnvironment mockEnvironment = new MockEnvironment();
+ mockEnvironment.setProperty("spring.cloud.kubernetes.client.namespace", NAMESPACE);
+
+ // simulate that environment already has a Fabric8ConfigMapPropertySource,
+ // otherwise we can't properly test reload functionality
+ ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(),
+ List.of(), Map.of(), true, CONFIG_MAP_NAME, NAMESPACE, false, true, true, RetryProperties.DEFAULT);
+ KubernetesNamespaceProvider namespaceProvider = new KubernetesNamespaceProvider(mockEnvironment);
+
+ PropertySource> propertySource = new VisibleFabric8ConfigMapPropertySourceLocator(kubernetesClient,
+ configMapConfigProperties, namespaceProvider)
+ .locate(mockEnvironment);
+
+ mockEnvironment.getPropertySources().addFirst(propertySource);
+ return mockEnvironment;
+ }
+
+ @Bean
+ @Primary
+ ConfigReloadProperties configReloadProperties() {
+ return new ConfigReloadProperties(true, true, false, ConfigReloadProperties.ReloadStrategy.REFRESH,
+ ConfigReloadProperties.ReloadDetectionMode.POLLING, Duration.ofMillis(2000), Set.of("non-default"),
+ false, Duration.ofSeconds(2));
+ }
+
+ @Bean
+ @Primary
+ ConfigMapConfigProperties configMapConfigProperties() {
+ return new ConfigMapConfigProperties(true, List.of(), List.of(), Map.of(), true, CONFIG_MAP_NAME, NAMESPACE,
+ false, true, FAIL_FAST, RetryProperties.DEFAULT);
+ }
+
+ @Bean
+ @Primary
+ KubernetesNamespaceProvider namespaceProvider(AbstractEnvironment environment) {
+ return new KubernetesNamespaceProvider(environment);
+ }
+
+ @Bean
+ @Primary
+ ConfigurationUpdateStrategy configurationUpdateStrategy() {
+ return new ConfigurationUpdateStrategy("to-console", () -> {
+ strategyCalled[0] = true;
+ });
+ }
+
+ @Bean
+ @Primary
+ Fabric8ConfigMapPropertySourceLocator fabric8ConfigMapPropertySourceLocator(
+ ConfigMapConfigProperties configMapConfigProperties, KubernetesNamespaceProvider namespaceProvider) {
+ return new VisibleFabric8ConfigMapPropertySourceLocator(kubernetesClient, configMapConfigProperties,
+ namespaceProvider);
+ }
+
+ }
+
+}
diff --git a/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/PollingReloadSecretTest.java b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/PollingReloadSecretTest.java
new file mode 100644
index 0000000000..68667637ce
--- /dev/null
+++ b/spring-cloud-kubernetes-fabric8-config/src/test/java/org/springframework/cloud/kubernetes/fabric8/config/reload_it/PollingReloadSecretTest.java
@@ -0,0 +1,212 @@
+/*
+ * Copyright 2012-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.kubernetes.fabric8.config.reload_it;
+
+import java.time.Duration;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import io.fabric8.kubernetes.api.model.Secret;
+import io.fabric8.kubernetes.api.model.SecretBuilder;
+import io.fabric8.kubernetes.api.model.SecretListBuilder;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient;
+import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
+import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider;
+import org.springframework.cloud.kubernetes.commons.config.RetryProperties;
+import org.springframework.cloud.kubernetes.commons.config.SecretsConfigProperties;
+import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties;
+import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy;
+import org.springframework.cloud.kubernetes.commons.config.reload.PollingSecretsChangeDetector;
+import org.springframework.cloud.kubernetes.fabric8.config.Fabric8SecretsPropertySource;
+import org.springframework.cloud.kubernetes.fabric8.config.Fabric8SecretsPropertySourceLocator;
+import org.springframework.cloud.kubernetes.fabric8.config.VisibleFabric8SecretsPropertySourceLocator;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Primary;
+import org.springframework.core.env.AbstractEnvironment;
+import org.springframework.core.env.PropertySource;
+import org.springframework.mock.env.MockEnvironment;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+
+/**
+ * @author wind57
+ */
+@SpringBootTest(
+ properties = { "spring.main.allow-bean-definition-overriding=true",
+ "logging.level.org.springframework.cloud.kubernetes.commons.config=debug" },
+ classes = { PollingReloadSecretTest.TestConfig.class })
+@EnableKubernetesMockClient
+@ExtendWith(OutputCaptureExtension.class)
+
+public class PollingReloadSecretTest {
+
+ private static final boolean FAIL_FAST = false;
+
+ private static final String SECRET_NAME = "mine";
+
+ private static final String NAMESPACE = "spring-k8s";
+
+ private static KubernetesMockServer kubernetesMockServer;
+
+ private static KubernetesClient kubernetesClient;
+
+ private static final boolean[] strategyCalled = new boolean[] { false };
+
+ @BeforeAll
+ static void beforeAll() {
+
+ kubernetesClient.getConfiguration().setRequestRetryBackoffLimit(0);
+
+ // needed so that our environment is populated with 'something'
+ // this call is done in the method that returns the AbstractEnvironment
+ Secret secretOne = secret(SECRET_NAME, Map.of());
+ Secret secretTwo = secret(SECRET_NAME, Map.of("a", "b"));
+ String path = "/api/v1/namespaces/spring-k8s/secrets";
+ kubernetesMockServer.expect()
+ .withPath(path)
+ .andReturn(200, new SecretListBuilder().withItems(secretOne).build())
+ .once();
+
+ kubernetesMockServer.expect().withPath(path).andReturn(500, "Internal Server Error").once();
+
+ kubernetesMockServer.expect()
+ .withPath(path)
+ .andReturn(200, new SecretListBuilder().withItems(secretTwo).build())
+ .once();
+ }
+
+ /**
+ *
+ * - we have a PropertySource in the environment
+ * - first polling cycle tries to read the sources from k8s and fails
+ * - second polling cycle reads sources from k8s and finds a change
+ *
+ */
+ @Test
+ void test(CapturedOutput output) {
+ // we fail while reading 'secretOne'
+ Awaitility.await().atMost(Duration.ofSeconds(10)).pollInterval(Duration.ofSeconds(1)).until(() -> {
+ boolean one = output.getOut().contains("failure in reading named sources");
+ boolean two = output.getOut()
+ .contains("there was an error while reading config maps/secrets, no reload will happen");
+ boolean three = output.getOut()
+ .contains("reloadable condition was not satisfied, reload will not be triggered");
+ boolean updateStrategyNotCalled = !strategyCalled[0];
+ return one && two && three && updateStrategyNotCalled;
+ });
+
+ // it passes while reading 'secretTwo'
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(10))
+ .pollInterval(Duration.ofSeconds(1))
+ .until(() -> strategyCalled[0]);
+ }
+
+ private static Secret secret(String name, Map data) {
+ Map encoded = data.entrySet()
+ .stream()
+ .collect(Collectors.toMap(Map.Entry::getKey,
+ e -> new String(Base64.getEncoder().encode(e.getValue().getBytes()))));
+ return new SecretBuilder().withNewMetadata().withName(name).endMetadata().withData(encoded).build();
+ }
+
+ @TestConfiguration
+ static class TestConfig {
+
+ @Bean
+ @Primary
+ PollingSecretsChangeDetector pollingSecretsChangeDetector(AbstractEnvironment environment,
+ ConfigReloadProperties configReloadProperties, ConfigurationUpdateStrategy configurationUpdateStrategy,
+ Fabric8SecretsPropertySourceLocator fabric8SecretsPropertySourceLocator) {
+ ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
+ scheduler.initialize();
+ return new PollingSecretsChangeDetector(environment, configReloadProperties, configurationUpdateStrategy,
+ Fabric8SecretsPropertySource.class, fabric8SecretsPropertySourceLocator, scheduler);
+ }
+
+ @Bean
+ @Primary
+ AbstractEnvironment environment() {
+ MockEnvironment mockEnvironment = new MockEnvironment();
+ mockEnvironment.setProperty("spring.cloud.kubernetes.client.namespace", NAMESPACE);
+
+ // simulate that environment already has a Fabric8SecretsPropertySource,
+ // otherwise we can't properly test reload functionality
+ SecretsConfigProperties secretsConfigProperties = new SecretsConfigProperties(true, Map.of(), List.of(),
+ List.of(), true, SECRET_NAME, NAMESPACE, false, true, true, RetryProperties.DEFAULT);
+ KubernetesNamespaceProvider namespaceProvider = new KubernetesNamespaceProvider(mockEnvironment);
+
+ PropertySource> propertySource = new VisibleFabric8SecretsPropertySourceLocator(kubernetesClient,
+ secretsConfigProperties, namespaceProvider)
+ .locate(mockEnvironment);
+
+ mockEnvironment.getPropertySources().addFirst(propertySource);
+ return mockEnvironment;
+ }
+
+ @Bean
+ @Primary
+ ConfigReloadProperties configReloadProperties() {
+ return new ConfigReloadProperties(true, true, true, ConfigReloadProperties.ReloadStrategy.REFRESH,
+ ConfigReloadProperties.ReloadDetectionMode.POLLING, Duration.ofMillis(2000), Set.of("non-default"),
+ false, Duration.ofSeconds(2));
+ }
+
+ @Bean
+ @Primary
+ SecretsConfigProperties secretsConfigProperties() {
+ return new SecretsConfigProperties(true, Map.of(), List.of(), List.of(), true, SECRET_NAME, NAMESPACE,
+ false, true, FAIL_FAST, RetryProperties.DEFAULT);
+ }
+
+ @Bean
+ @Primary
+ KubernetesNamespaceProvider namespaceProvider(AbstractEnvironment environment) {
+ return new KubernetesNamespaceProvider(environment);
+ }
+
+ @Bean
+ @Primary
+ ConfigurationUpdateStrategy configurationUpdateStrategy() {
+ return new ConfigurationUpdateStrategy("to-console", () -> {
+ strategyCalled[0] = true;
+ });
+ }
+
+ @Bean
+ @Primary
+ Fabric8SecretsPropertySourceLocator fabric8SecretsPropertySourceLocator(
+ SecretsConfigProperties secretsConfigProperties, KubernetesNamespaceProvider namespaceProvider) {
+ return new VisibleFabric8SecretsPropertySourceLocator(kubernetesClient, secretsConfigProperties,
+ namespaceProvider);
+ }
+
+ }
+
+}