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