Fix 1592 empty source on error (#1792)

This commit is contained in:
erabii
2024-11-22 15:45:57 +02:00
committed by GitHub
parent abc0a89da6
commit b47f91dcf0
22 changed files with 3098 additions and 46 deletions

View File

@@ -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();
}
/**
* <pre>
* we try to read all config maps in a namespace and fail,
* thus generate a well defined name for the source.
* </pre>
*/
@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");
}
/**
* <pre>
* there are two sources and we try to read them.
* one fails and one passes.
* </pre>
*/
@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<String> 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");
}
/**
* <pre>
* there are two sources and we try to read them.
* both fail.
* </pre>
*/
@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<String> 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");
}
/**
* <pre>
* we try to read all config maps in a namespace and fail,
* thus generate a well defined name for the source.
* </pre>
*/
@Test
void labeledSingleConfigMapFails(CapturedOutput output) {
Map<String, String> 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<String> 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");
}
/**
* <pre>
* there are two sources and we try to read them.
* one fails and one passes.
* </pre>
*/
@Test
void labeledTwoConfigMapsOneFails(CapturedOutput output) {
Map<String, String> configMapOneLabels = Map.of("one", "1");
Map<String, String> 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<String> 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");
}
/**
* <pre>
* there are two sources and we try to read them.
* both fail.
* </pre>
*/
@Test
void labeledTwoConfigMapsBothFail(CapturedOutput output) {
Map<String, String> configMapOneLabels = Map.of("one", "1");
Map<String, String> 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<String> 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");
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<KubernetesClientUtils> 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();
}
/**
* <pre>
* - '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
* </pre>
*/
@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<String, String> 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);
}
}
}

View File

@@ -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<KubernetesClientUtils> 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();
}
/**
* <pre>
* - '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
* </pre>
*/
@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<String, String> data) {
Map<String, byte[]> 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);
}
}
}

View File

@@ -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();
}
/**
* <pre>
* - 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
* </pre>
*/
@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<String, String> 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);
}
}
}

View File

@@ -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();
}
/**
* <pre>
* - 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
* </pre>
*/
@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<String, String> data) {
Map<String, byte[]> 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);
}
}
}

View File

@@ -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() {
}

View File

@@ -21,7 +21,11 @@ import java.util.Map;
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 @@ import static org.springframework.cloud.kubernetes.commons.config.Constants.PROP
*/
public abstract class LabeledSourceData {
private static final Log LOG = LogFactory.getLog(LabeledSourceData.class);
public final SourceData compute(Map<String, String> labels, ConfigUtils.Prefix prefix, String target,
boolean profileSources, boolean failFast, String namespace, String[] activeProfiles) {
@@ -73,7 +79,9 @@ public abstract class LabeledSourceData {
}
}
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));

View File

@@ -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 abstract class NamedSourceData {
}
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));

View File

@@ -33,6 +33,8 @@ import org.springframework.core.env.MapPropertySource;
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 final class ConfigReloadUtil {
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 @@ public final class ConfigReloadUtil {
}
static boolean changed(List<? extends MapPropertySource> k8sSources, List<? extends MapPropertySource> appSources) {
if (k8sSources.stream().anyMatch(source -> "true".equals(source.getProperty(ERROR_PROPERTY)))) {
LOG.info(() -> "there was an error while reading config maps/secrets, no reload will happen");
return false;
}
if (k8sSources.size() != appSources.size()) {
if (LOG.isDebugEnabled()) {
LOG.debug("k8s property sources size: " + k8sSources.size());

View File

@@ -26,6 +26,7 @@ import org.junit.jupiter.api.Assertions;
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 @@ class ConfigReloadUtilTests {
Assertions.assertEquals("from-inner-two-composite", result.get(3).getProperty(""));
}
@Test
void testEmptySourceNameOnError() {
Object value = new Object();
Map<String, Object> 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() {

View File

@@ -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);
}
/**
* <pre>
* we try to read all config maps in a namespace and fail,
* thus generate a well defined name for the source.
* </pre>
*/
@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");
}
/**
* <pre>
* there are two sources and we try to read them.
* one fails and one passes.
* </pre>
*/
@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<String> 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");
}
/**
* <pre>
* there are two sources and we try to read them.
* both fail.
* </pre>
*/
@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<String> names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList();
assertThat(names).containsExactly("configmap..default");
assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true");
}
/**
* <pre>
* we try to read all config maps in a namespace and fail,
* thus generate a well defined name for the source.
* </pre>
*/
@Test
void labeledSingleConfigMapFails(CapturedOutput output) {
Map<String, String> 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<String> 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");
}
/**
* <pre>
* there are two sources and we try to read them.
* one fails and one passes.
* </pre>
*/
@Test
void labeledTwoConfigMapsOneFails(CapturedOutput output) {
String configMapNameOne = "one";
String configMapNameTwo = "two";
Map<String, String> configMapOneLabels = Map.of("one", "1");
Map<String, String> 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<String> 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");
}
/**
* <pre>
* there are two sources and we try to read them.
* both fail.
* </pre>
*/
@Test
void labeledTwoConfigMapsBothFail(CapturedOutput output) {
Map<String, String> configMapOneLabels = Map.of("one", "1");
Map<String, String> 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<String> 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<String, String> labels) {
return new ConfigMapBuilder().withNewMetadata().withName(name).withLabels(labels).endMetadata().build();
}
}

View File

@@ -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);
}
/**
* <pre>
* we try to read all secrets in a namespace and fail,
* thus generate a well defined name for the source.
* </pre>
*/
@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");
}
/**
* <pre>
* there are two sources and we try to read them.
* one fails and one passes.
* </pre>
*/
@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<String> 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");
}
/**
* <pre>
* there are two sources and we try to read them.
* both fail.
* </pre>
*/
@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<String> names = propertySource.getPropertySources().stream().map(PropertySource::getName).toList();
assertThat(names).containsExactly("secret..default");
assertThat(propertySource.getProperty(Constants.ERROR_PROPERTY)).isEqualTo("true");
}
/**
* <pre>
* we try to read all secrets in a namespace and fail,
* thus generate a well defined name for the source.
* </pre>
*/
@Test
void labeledSingleSecretFails(CapturedOutput output) {
Map<String, String> 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<String> 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");
}
/**
* <pre>
* there are two sources and we try to read them.
* one fails and one passes.
* </pre>
*/
@Test
void labeledTwoSecretsOneFails(CapturedOutput output) {
String secretNameOne = "one";
String secretNameTwo = "two";
Map<String, String> secretOneLabels = Map.of("one", "1");
Map<String, String> 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<String> 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");
}
/**
* <pre>
* there are two sources and we try to read them.
* both fail.
* </pre>
*/
@Test
void labeledTwoConfigMapsBothFail(CapturedOutput output) {
Map<String, String> secretOneLabels = Map.of("one", "1");
Map<String, String> 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<String> 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<String, String> labels) {
return new SecretBuilder().withNewMetadata().withName(name).withLabels(labels).endMetadata().build();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<ConfigMap, ConfigMapList, Resource<ConfigMap>> 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<ConfigMap, ConfigMapList, Resource<ConfigMap>> mixedOperation = Mockito
.mock(MixedOperation.class);
NonNamespaceOperation<ConfigMap, ConfigMapList, Resource<ConfigMap>> 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<String, String> 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);
}
}
}

View File

@@ -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<Secret, SecretList, Resource<Secret>> 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<Secret, SecretList, Resource<Secret>> mixedOperation = Mockito.mock(MixedOperation.class);
NonNamespaceOperation<Secret, SecretList, Resource<Secret>> 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<String, String> data) {
Map<String, String> 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);
}
}
}

View File

@@ -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();
}
/**
* <pre>
* - 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
* </pre>
*/
@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<String, String> 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);
}
}
}

View File

@@ -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();
}
/**
* <pre>
* - 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
* </pre>
*/
@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<String, String> data) {
Map<String, String> 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);
}
}
}