fixes 1176 (#1185)

This commit is contained in:
erabii
2023-01-06 17:13:52 +02:00
committed by GitHub
parent f66ad3168a
commit 892af0f601
34 changed files with 1016 additions and 123 deletions

View File

@@ -63,7 +63,7 @@ public class KubernetesClientConfigDataLocationResolver extends KubernetesConfig
coreV1Api, configMapProperties, namespaceProvider);
if (isRetryEnabledForConfigMap(configMapProperties)) {
configMapPropertySourceLocator = new ConfigDataRetryableConfigMapPropertySourceLocator(
configMapPropertySourceLocator, configMapProperties);
configMapPropertySourceLocator, configMapProperties, new KubernetesClientConfigMapsCache());
}
registerSingle(bootstrapContext, ConfigMapPropertySourceLocator.class, configMapPropertySourceLocator,
@@ -75,7 +75,7 @@ public class KubernetesClientConfigDataLocationResolver extends KubernetesConfig
coreV1Api, namespaceProvider, secretsProperties);
if (isRetryEnabledForSecrets(secretsProperties)) {
secretsPropertySourceLocator = new ConfigDataRetryableSecretsPropertySourceLocator(
secretsPropertySourceLocator, secretsProperties);
secretsPropertySourceLocator, secretsProperties, new KubernetesClientSecretsCache());
}
registerSingle(bootstrapContext, SecretsPropertySourceLocator.class, secretsPropertySourceLocator,

View File

@@ -39,7 +39,7 @@ public class KubernetesClientConfigMapPropertySourceLocator extends ConfigMapPro
public KubernetesClientConfigMapPropertySourceLocator(CoreV1Api coreV1Api, ConfigMapConfigProperties properties,
KubernetesNamespaceProvider kubernetesNamespaceProvider) {
super(properties);
super(properties, new KubernetesClientConfigMapsCache());
this.coreV1Api = coreV1Api;
this.kubernetesNamespaceProvider = kubernetesNamespaceProvider;
}

View File

@@ -0,0 +1,82 @@
/*
* 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 java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import io.kubernetes.client.openapi.ApiException;
import io.kubernetes.client.openapi.apis.CoreV1Api;
import io.kubernetes.client.openapi.models.V1ConfigMap;
import org.apache.commons.logging.LogFactory;
import org.springframework.cloud.kubernetes.commons.config.ConfigMapCache;
import org.springframework.cloud.kubernetes.commons.config.StrippedSourceContainer;
import org.springframework.core.log.LogAccessor;
/**
* A cache of V1ConfigMap(s) per namespace. Makes sure we read config maps only once from
* a namespace.
*
* @author wind57
*/
final class KubernetesClientConfigMapsCache implements ConfigMapCache {
private static final LogAccessor LOG = new LogAccessor(LogFactory.getLog(KubernetesClientConfigMapsCache.class));
/**
* at the moment our loading of config maps is using a single thread, but might change
* in the future, thus a thread safe structure.
*/
private static final ConcurrentHashMap<String, List<StrippedSourceContainer>> CACHE = new ConcurrentHashMap<>();
@Override
public void discardAll() {
CACHE.clear();
}
static List<StrippedSourceContainer> byNamespace(CoreV1Api coreV1Api, String namespace) {
boolean[] b = new boolean[1];
List<StrippedSourceContainer> result = CACHE.computeIfAbsent(namespace, x -> {
try {
b[0] = true;
return strippedConfigMaps(coreV1Api
.listNamespacedConfigMap(namespace, null, null, null, null, null, null, null, null, null, null)
.getItems());
}
catch (ApiException apiException) {
throw new RuntimeException(apiException.getResponseBody(), apiException);
}
});
if (b[0]) {
LOG.debug(() -> "Loaded all config maps in namespace '" + namespace + "'");
}
else {
LOG.debug(() -> "Loaded (from cache) all config maps in namespace '" + namespace + "'");
}
return result;
}
private static List<StrippedSourceContainer> strippedConfigMaps(List<V1ConfigMap> configMaps) {
return configMaps.stream().map(configMap -> new StrippedSourceContainer(configMap.getMetadata().getLabels(),
configMap.getMetadata().getName(), configMap.getData())).collect(Collectors.toList());
}
}

View File

@@ -20,12 +20,8 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import io.kubernetes.client.openapi.ApiException;
import io.kubernetes.client.openapi.apis.CoreV1Api;
import io.kubernetes.client.openapi.models.V1ConfigMap;
import io.kubernetes.client.openapi.models.V1Secret;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
@@ -75,16 +71,13 @@ public final class KubernetesClientConfigUtils {
* 6. gather all the names of the secrets (from 4) + data they hold
* </pre>
*/
static MultipleSourcesContainer secretsDataByLabels(CoreV1Api client, String namespace, Map<String, String> labels,
Environment environment, Set<String> profiles) {
List<V1Secret> secrets = secretsSearch(client, namespace);
if (ConfigUtils.noSources(secrets, namespace)) {
static MultipleSourcesContainer secretsDataByLabels(CoreV1Api coreV1Api, String namespace,
Map<String, String> labels, Environment environment, Set<String> profiles) {
List<StrippedSourceContainer> strippedSecrets = strippedSecrets(coreV1Api, namespace);
if (strippedSecrets.isEmpty()) {
return MultipleSourcesContainer.empty();
}
List<StrippedSourceContainer> strippedSources = strippedSecrets(secrets);
return ConfigUtils.processLabeledData(strippedSources, environment, labels, namespace, profiles, DECODE);
return ConfigUtils.processLabeledData(strippedSecrets, environment, labels, namespace, profiles, DECODE);
}
/**
@@ -97,16 +90,13 @@ public final class KubernetesClientConfigUtils {
* 6. gather all the names of the config maps (from 4) + data they hold
* </pre>
*/
static MultipleSourcesContainer configMapsDataByLabels(CoreV1Api client, String namespace,
static MultipleSourcesContainer configMapsDataByLabels(CoreV1Api coreV1Api, String namespace,
Map<String, String> labels, Environment environment, Set<String> profiles) {
List<V1ConfigMap> configMaps = configMapsSearch(client, namespace);
if (ConfigUtils.noSources(configMaps, namespace)) {
List<StrippedSourceContainer> strippedConfigMaps = strippedConfigMaps(coreV1Api, namespace);
if (strippedConfigMaps.isEmpty()) {
return MultipleSourcesContainer.empty();
}
List<StrippedSourceContainer> strippedSources = strippedConfigMaps(configMaps);
return ConfigUtils.processLabeledData(strippedSources, environment, labels, namespace, profiles, DECODE);
return ConfigUtils.processLabeledData(strippedConfigMaps, environment, labels, namespace, profiles, DECODE);
}
/**
@@ -117,16 +107,13 @@ public final class KubernetesClientConfigUtils {
* 4. gather all the names of the secrets + decoded data they hold
* </pre>
*/
static MultipleSourcesContainer secretsDataByName(CoreV1Api client, String namespace,
static MultipleSourcesContainer secretsDataByName(CoreV1Api coreV1Api, String namespace,
LinkedHashSet<String> sourceNames, Environment environment) {
List<V1Secret> secrets = secretsSearch(client, namespace);
if (ConfigUtils.noSources(secrets, namespace)) {
List<StrippedSourceContainer> strippedSecrets = strippedSecrets(coreV1Api, namespace);
if (strippedSecrets.isEmpty()) {
return MultipleSourcesContainer.empty();
}
List<StrippedSourceContainer> strippedSources = strippedSecrets(secrets);
return ConfigUtils.processNamedData(strippedSources, environment, sourceNames, namespace, DECODE);
return ConfigUtils.processNamedData(strippedSecrets, environment, sourceNames, namespace, DECODE);
}
/**
@@ -137,52 +124,30 @@ public final class KubernetesClientConfigUtils {
* 4. gather all the names of the config maps + data they hold
* </pre>
*/
static MultipleSourcesContainer configMapsDataByName(CoreV1Api client, String namespace,
static MultipleSourcesContainer configMapsDataByName(CoreV1Api coreV1Api, String namespace,
LinkedHashSet<String> sourceNames, Environment environment) {
List<V1ConfigMap> configMaps = configMapsSearch(client, namespace);
if (ConfigUtils.noSources(configMaps, namespace)) {
List<StrippedSourceContainer> strippedConfigMaps = strippedConfigMaps(coreV1Api, namespace);
if (strippedConfigMaps.isEmpty()) {
return MultipleSourcesContainer.empty();
}
List<StrippedSourceContainer> strippedSources = strippedConfigMaps(configMaps);
return ConfigUtils.processNamedData(strippedSources, environment, sourceNames, namespace, DECODE);
return ConfigUtils.processNamedData(strippedConfigMaps, environment, sourceNames, namespace, DECODE);
}
private static List<V1Secret> secretsSearch(CoreV1Api client, String namespace) {
LOG.debug("Loading all secrets in namespace '" + namespace + "'");
try {
return client.listNamespacedSecret(namespace, null, null, null, null, null, null, null, null, null, null)
.getItems();
private static List<StrippedSourceContainer> strippedConfigMaps(CoreV1Api coreV1Api, String namespace) {
List<StrippedSourceContainer> strippedConfigMaps = KubernetesClientConfigMapsCache.byNamespace(coreV1Api,
namespace);
if (strippedConfigMaps.isEmpty()) {
LOG.debug("No configmaps in namespace '" + namespace + "'");
}
catch (ApiException apiException) {
throw new RuntimeException(apiException.getResponseBody(), apiException);
return strippedConfigMaps;
}
private static List<StrippedSourceContainer> strippedSecrets(CoreV1Api coreV1Api, String namespace) {
List<StrippedSourceContainer> strippedSecrets = KubernetesClientSecretsCache.byNamespace(coreV1Api, namespace);
if (strippedSecrets.isEmpty()) {
LOG.debug("No configmaps in namespace '" + namespace + "'");
}
}
private static List<V1ConfigMap> configMapsSearch(CoreV1Api client, String namespace) {
LOG.debug("Loading all config maps in namespace '" + namespace + "'");
try {
return client.listNamespacedConfigMap(namespace, null, null, null, null, null, null, null, null, null, null)
.getItems();
}
catch (ApiException apiException) {
throw new RuntimeException(apiException.getResponseBody(), apiException);
}
}
private static List<StrippedSourceContainer> strippedSecrets(List<V1Secret> secrets) {
return secrets.stream().map(secret -> new StrippedSourceContainer(secret.getMetadata().getLabels(),
secret.getMetadata().getName(), transform(secret.getData()))).collect(Collectors.toList());
}
private static List<StrippedSourceContainer> strippedConfigMaps(List<V1ConfigMap> configMaps) {
return configMaps.stream().map(configMap -> new StrippedSourceContainer(configMap.getMetadata().getLabels(),
configMap.getMetadata().getName(), configMap.getData())).collect(Collectors.toList());
}
private static Map<String, String> transform(Map<String, byte[]> in) {
return in.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, en -> new String(en.getValue())));
return strippedSecrets;
}
}

View File

@@ -0,0 +1,87 @@
/*
* 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 java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import io.kubernetes.client.openapi.ApiException;
import io.kubernetes.client.openapi.apis.CoreV1Api;
import io.kubernetes.client.openapi.models.V1Secret;
import org.apache.commons.logging.LogFactory;
import org.springframework.cloud.kubernetes.commons.config.SecretsCache;
import org.springframework.cloud.kubernetes.commons.config.StrippedSourceContainer;
import org.springframework.core.log.LogAccessor;
/**
* A cache of V1ConfigMap(s) per namespace. Makes sure we read config maps only once from
* a namespace.
*
* @author wind57
*/
public class KubernetesClientSecretsCache implements SecretsCache {
private static final LogAccessor LOG = new LogAccessor(LogFactory.getLog(KubernetesClientConfigMapsCache.class));
/**
* at the moment our loading of config maps is using a single thread, but might change
* in the future, thus a thread safe structure.
*/
private static final ConcurrentHashMap<String, List<StrippedSourceContainer>> CACHE = new ConcurrentHashMap<>();
@Override
public void discardAll() {
CACHE.clear();
}
static List<StrippedSourceContainer> byNamespace(CoreV1Api coreV1Api, String namespace) {
boolean[] b = new boolean[1];
List<StrippedSourceContainer> result = CACHE.computeIfAbsent(namespace, x -> {
try {
b[0] = true;
return strippedSecrets(coreV1Api
.listNamespacedSecret(namespace, null, null, null, null, null, null, null, null, null, null)
.getItems());
}
catch (ApiException apiException) {
throw new RuntimeException(apiException.getResponseBody(), apiException);
}
});
if (b[0]) {
LOG.debug(() -> "Loaded all secrets in namespace '" + namespace + "'");
}
else {
LOG.debug(() -> "Loaded (from cache) all secrets in namespace '" + namespace + "'");
}
return result;
}
private static List<StrippedSourceContainer> strippedSecrets(List<V1Secret> secrets) {
return secrets.stream().map(secret -> new StrippedSourceContainer(secret.getMetadata().getLabels(),
secret.getMetadata().getName(), transform(secret.getData()))).collect(Collectors.toList());
}
private static Map<String, String> transform(Map<String, byte[]> in) {
return in.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, en -> new String(en.getValue())));
}
}

View File

@@ -39,7 +39,7 @@ public class KubernetesClientSecretsPropertySourceLocator extends SecretsPropert
public KubernetesClientSecretsPropertySourceLocator(CoreV1Api coreV1Api,
KubernetesNamespaceProvider kubernetesNamespaceProvider, SecretsConfigProperties secretsConfigProperties) {
super(secretsConfigProperties);
super(secretsConfigProperties, new KubernetesClientSecretsCache());
this.coreV1Api = coreV1Api;
this.kubernetesNamespaceProvider = kubernetesNamespaceProvider;
}

View File

@@ -93,6 +93,7 @@ class KubernetesClientConfigMapPropertySourceTests {
@AfterEach
public void afterEach() {
WireMock.reset();
new KubernetesClientConfigMapsCache().discardAll();
}
@Test

View File

@@ -112,6 +112,7 @@ class KubernetesClientSecretsPropertySourceTests {
@AfterEach
void afterEach() {
WireMock.reset();
new KubernetesClientSecretsCache().discardAll();
}
@Test

View File

@@ -36,7 +36,10 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
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.config.ConfigUtils;
import org.springframework.cloud.kubernetes.commons.config.LabeledConfigMapNormalizedSource;
import org.springframework.cloud.kubernetes.commons.config.NormalizedSource;
@@ -51,6 +54,7 @@ import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options
/**
* @author wind57
*/
@ExtendWith(OutputCaptureExtension.class)
class LabeledConfigMapContextToSourceDataProviderTests {
private static final Map<String, String> LABELS = new LinkedHashMap<>();
@@ -83,6 +87,7 @@ class LabeledConfigMapContextToSourceDataProviderTests {
@AfterEach
void afterEach() {
WireMock.reset();
new KubernetesClientConfigMapsCache().discardAll();
}
/**
@@ -445,6 +450,62 @@ class LabeledConfigMapContextToSourceDataProviderTests {
}
/**
* <pre>
* - one configmap is deployed with label {"color", "red"}
* - one configmap is deployed with label {"color", "green"}
*
* - we first search for "red" and find it, and it is retrieved from the cluster via the client.
* - we then search for the "green" one, and it is retrieved from the cache this time.
* </pre>
*/
@Test
void cache(CapturedOutput output) {
V1ConfigMap red = new V1ConfigMapBuilder().withMetadata(new V1ObjectMetaBuilder()
.withLabels(Map.of("color", "red")).withNamespace(NAMESPACE).withName("red-configmap").build())
.addToData("color", "red").build();
V1ConfigMap green = new V1ConfigMapBuilder().withMetadata(new V1ObjectMetaBuilder()
.withLabels(Map.of("color", "green")).withNamespace(NAMESPACE).withName("green-configmap").build())
.addToData("color", "green").build();
V1ConfigMapList configMapList = new V1ConfigMapList().addItemsItem(red).addItemsItem(green);
stubCall(configMapList);
CoreV1Api api = new CoreV1Api();
NormalizedSource redSource = new LabeledConfigMapNormalizedSource(NAMESPACE, Map.of("color", "red"), false,
ConfigUtils.Prefix.DEFAULT, false);
KubernetesClientConfigContext redContext = new KubernetesClientConfigContext(api, redSource, NAMESPACE,
new MockEnvironment());
KubernetesClientContextToSourceData redData = new LabeledConfigMapContextToSourceDataProvider().get();
SourceData redSourceData = redData.apply(redContext);
Assertions.assertEquals(redSourceData.sourceData().size(), 1);
Assertions.assertEquals(redSourceData.sourceData().get("color"), "red");
Assertions.assertEquals(redSourceData.sourceName(), "configmap.red-configmap.default");
Assertions.assertTrue(output.getAll().contains("Loaded all config maps in namespace '" + NAMESPACE + "'"));
NormalizedSource greenSource = new LabeledConfigMapNormalizedSource(NAMESPACE, Map.of("color", "green"), false,
ConfigUtils.Prefix.DEFAULT, false);
KubernetesClientConfigContext greenContext = new KubernetesClientConfigContext(api, greenSource, NAMESPACE,
new MockEnvironment());
KubernetesClientContextToSourceData greenData = new LabeledConfigMapContextToSourceDataProvider().get();
SourceData greenSourceData = greenData.apply(greenContext);
Assertions.assertEquals(greenSourceData.sourceData().size(), 1);
Assertions.assertEquals(greenSourceData.sourceData().get("color"), "green");
Assertions.assertEquals(greenSourceData.sourceName(), "configmap.green-configmap.default");
// meaning there is a single entry with such a log statement
String[] out = output.getAll().split("Loaded all config maps in namespace");
Assertions.assertEquals(out.length, 2);
// meaning that the second read was done from the cache
out = output.getAll().split("Loaded \\(from cache\\) all config maps in namespace");
Assertions.assertEquals(out.length, 2);
}
private void stubCall(V1ConfigMapList list) {
stubFor(get("/api/v1/namespaces/default/configmaps")
.willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(list))));

View File

@@ -37,7 +37,10 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
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.config.ConfigUtils;
import org.springframework.cloud.kubernetes.commons.config.LabeledSecretNormalizedSource;
import org.springframework.cloud.kubernetes.commons.config.NormalizedSource;
@@ -52,6 +55,7 @@ import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options
/**
* @author wind57
*/
@ExtendWith(OutputCaptureExtension.class)
class LabeledSecretContextToSourceDataProviderTests {
private static final Map<String, String> LABELS = new LinkedHashMap<>();
@@ -80,6 +84,7 @@ class LabeledSecretContextToSourceDataProviderTests {
@AfterEach
void afterEach() {
WireMock.reset();
new KubernetesClientSecretsCache().discardAll();
}
/**
@@ -439,6 +444,61 @@ class LabeledSecretContextToSourceDataProviderTests {
Assertions.assertEquals(sourceData.sourceName(), "secret.color-secret.default");
}
/**
* <pre>
* - one secret is deployed with label {"color", "red"}
* - one secret is deployed with label {"color", "green"}
*
* - we first search for "red" and find it, and it is retrieved from the cluster via the client.
* - we then search for the "green" one, and it is retrieved from the cache this time.
* </pre>
*/
@Test
void cache(CapturedOutput output) {
V1Secret red = new V1SecretBuilder().withMetadata(new V1ObjectMetaBuilder().withLabels(Map.of("color", "red"))
.withNamespace(NAMESPACE).withName("red").build()).addToData("color", "red".getBytes()).build();
V1Secret green = new V1SecretBuilder().withMetadata(new V1ObjectMetaBuilder()
.withLabels(Map.of("color", "green")).withNamespace(NAMESPACE).withName("green").build())
.addToData("color", "green".getBytes()).build();
V1SecretList secretList = new V1SecretList().addItemsItem(red).addItemsItem(green);
stubCall(secretList);
CoreV1Api api = new CoreV1Api();
NormalizedSource redSource = new LabeledSecretNormalizedSource(NAMESPACE, Map.of("color", "red"), false,
ConfigUtils.Prefix.DEFAULT, false);
KubernetesClientConfigContext redContext = new KubernetesClientConfigContext(api, redSource, NAMESPACE,
new MockEnvironment());
KubernetesClientContextToSourceData redData = new LabeledSecretContextToSourceDataProvider().get();
SourceData redSourceData = redData.apply(redContext);
Assertions.assertEquals(redSourceData.sourceData().size(), 1);
Assertions.assertEquals(redSourceData.sourceData().get("color"), "red");
Assertions.assertEquals(redSourceData.sourceName(), "secret.red.default");
Assertions.assertTrue(output.getAll().contains("Loaded all secrets in namespace '" + NAMESPACE + "'"));
NormalizedSource greenSource = new LabeledSecretNormalizedSource(NAMESPACE, Map.of("color", "green"), false,
ConfigUtils.Prefix.DEFAULT, false);
KubernetesClientConfigContext greenContext = new KubernetesClientConfigContext(api, greenSource, NAMESPACE,
new MockEnvironment());
KubernetesClientContextToSourceData greenData = new LabeledSecretContextToSourceDataProvider().get();
SourceData greenSourceData = greenData.apply(greenContext);
Assertions.assertEquals(greenSourceData.sourceData().size(), 1);
Assertions.assertEquals(greenSourceData.sourceData().get("color"), "green");
Assertions.assertEquals(greenSourceData.sourceName(), "secret.green.default");
// meaning there is a single entry with such a log statement
String[] out = output.getAll().split("Loaded all secrets in namespace");
Assertions.assertEquals(out.length, 2);
// meaning that the second read was done from the cache
out = output.getAll().split("Loaded \\(from cache\\) all secrets in namespace");
Assertions.assertEquals(out.length, 2);
}
private void stubCall(V1SecretList list) {
stubFor(get("/api/v1/namespaces/default/secrets")
.willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(list))));

View File

@@ -34,7 +34,10 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
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.config.ConfigUtils;
import org.springframework.cloud.kubernetes.commons.config.NamedConfigMapNormalizedSource;
import org.springframework.cloud.kubernetes.commons.config.NormalizedSource;
@@ -49,6 +52,7 @@ import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options
/**
* @author wind57
*/
@ExtendWith(OutputCaptureExtension.class)
class NamedConfigMapContextToSourceDataProviderTests {
private static final String NAMESPACE = "default";
@@ -78,6 +82,7 @@ class NamedConfigMapContextToSourceDataProviderTests {
@AfterEach
void afterEach() {
WireMock.reset();
new KubernetesClientConfigMapsCache().discardAll();
}
/**
@@ -379,6 +384,61 @@ class NamedConfigMapContextToSourceDataProviderTests {
Assertions.assertEquals(sourceData.sourceData(), Map.of("key", "value"));
}
/**
* <pre>
* - one configmap is deployed with name "red"
* - one configmap is deployed with name "green"
*
* - we first search for "red" and find it, and it is retrieved from the cluster via the client.
* - we then search for the "green" one, and it is retrieved from the cache this time.
* </pre>
*/
@Test
void cache(CapturedOutput output) {
V1ConfigMap red = new V1ConfigMapBuilder()
.withMetadata(new V1ObjectMetaBuilder().withName("red").withNamespace(NAMESPACE).build())
.addToData("color", "red").build();
V1ConfigMap green = new V1ConfigMapBuilder()
.withMetadata(new V1ObjectMetaBuilder().withName("green").withNamespace(NAMESPACE).build())
.addToData("color", "green").build();
V1ConfigMapList configMapList = new V1ConfigMapList().addItemsItem(red).addItemsItem(green);
stubCall(configMapList);
CoreV1Api api = new CoreV1Api();
MockEnvironment environment = new MockEnvironment();
NormalizedSource redSource = new NamedConfigMapNormalizedSource("red", NAMESPACE, true, false);
KubernetesClientConfigContext redContext = new KubernetesClientConfigContext(api, redSource, NAMESPACE,
environment);
KubernetesClientContextToSourceData redData = new NamedConfigMapContextToSourceDataProvider().get();
SourceData redSourceData = redData.apply(redContext);
Assertions.assertEquals(redSourceData.sourceName(), "configmap.red.default");
Assertions.assertEquals(redSourceData.sourceData(), Map.of("color", "red"));
Assertions.assertTrue(output.getAll().contains("Loaded all config maps in namespace '" + NAMESPACE + "'"));
NormalizedSource greenSource = new NamedConfigMapNormalizedSource("green", NAMESPACE, true, true);
KubernetesClientConfigContext greenContext = new KubernetesClientConfigContext(api, greenSource, NAMESPACE,
environment);
KubernetesClientContextToSourceData greenData = new NamedConfigMapContextToSourceDataProvider().get();
SourceData greenSourceData = greenData.apply(greenContext);
Assertions.assertEquals(greenSourceData.sourceName(), "configmap.green.default");
Assertions.assertEquals(greenSourceData.sourceData(), Map.of("color", "green"));
// meaning there is a single entry with such a log statement
String[] out = output.getAll().split("Loaded all config maps in namespace");
Assertions.assertEquals(out.length, 2);
// meaning that the second read was done from the cache
out = output.getAll().split("Loaded \\(from cache\\) all config maps in namespace");
Assertions.assertEquals(out.length, 2);
}
private void stubCall(V1ConfigMapList list) {
stubFor(get("/api/v1/namespaces/default/configmaps")
.willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(list))));

View File

@@ -35,7 +35,10 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
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.config.ConfigUtils;
import org.springframework.cloud.kubernetes.commons.config.NamedSecretNormalizedSource;
import org.springframework.cloud.kubernetes.commons.config.NormalizedSource;
@@ -47,6 +50,7 @@ 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;
@ExtendWith(OutputCaptureExtension.class)
class NamedSecretContextToSourceDataProviderTests {
private static final ConfigUtils.Prefix PREFIX = ConfigUtils.findPrefix("some", false, false, "irrelevant");
@@ -70,6 +74,7 @@ class NamedSecretContextToSourceDataProviderTests {
@AfterEach
void afterEach() {
WireMock.reset();
new KubernetesClientSecretsCache().discardAll();
}
/**
@@ -333,6 +338,61 @@ class NamedSecretContextToSourceDataProviderTests {
Assertions.assertEquals(sourceData.sourceData(), Map.of("key", "value"));
}
/**
* <pre>
* - one secret is deployed with name "red"
* - one secret is deployed with name "green"
*
* - we first search for "red" and find it, and it is retrieved from the cluster via the client.
* - we then search for the "green" one, and it is retrieved from the cache this time.
* </pre>
*/
@Test
void cache(CapturedOutput output) {
V1Secret red = new V1SecretBuilder()
.withMetadata(new V1ObjectMetaBuilder().withName("red").withNamespace(NAMESPACE).build())
.addToData("color", "red".getBytes()).build();
V1Secret green = new V1SecretBuilder()
.withMetadata(new V1ObjectMetaBuilder().withName("green").withNamespace(NAMESPACE).build())
.addToData("color", "green".getBytes()).build();
V1SecretList configMapList = new V1SecretList().addItemsItem(red).addItemsItem(green);
stubCall(configMapList);
CoreV1Api api = new CoreV1Api();
MockEnvironment environment = new MockEnvironment();
NormalizedSource redSource = new NamedSecretNormalizedSource("red", NAMESPACE, true, false);
KubernetesClientConfigContext redContext = new KubernetesClientConfigContext(api, redSource, NAMESPACE,
environment);
KubernetesClientContextToSourceData redData = new NamedSecretContextToSourceDataProvider().get();
SourceData redSourceData = redData.apply(redContext);
Assertions.assertEquals(redSourceData.sourceName(), "secret.red.default");
Assertions.assertEquals(redSourceData.sourceData(), Map.of("color", "red"));
Assertions.assertTrue(output.getAll().contains("Loaded all secrets in namespace '" + NAMESPACE + "'"));
NormalizedSource greenSource = new NamedSecretNormalizedSource("green", NAMESPACE, true, true);
KubernetesClientConfigContext greenContext = new KubernetesClientConfigContext(api, greenSource, NAMESPACE,
environment);
KubernetesClientContextToSourceData greenData = new NamedSecretContextToSourceDataProvider().get();
SourceData greenSourceData = greenData.apply(greenContext);
Assertions.assertEquals(greenSourceData.sourceName(), "secret.green.default");
Assertions.assertEquals(greenSourceData.sourceData(), Map.of("color", "green"));
// meaning there is a single entry with such a log statement
String[] out = output.getAll().split("Loaded all secrets in namespace");
Assertions.assertEquals(out.length, 2);
// meaning that the second read was done from the cache
out = output.getAll().split("Loaded \\(from cache\\) all secrets in namespace");
Assertions.assertEquals(out.length, 2);
}
private void stubCall(V1SecretList list) {
stubFor(get("/api/v1/namespaces/default/secrets")
.willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(list))));

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/base.xml"/>
<!-- needed for CapturedOutput -->
<logger name="org.springframework.cloud.kubernetes.client.config" level="DEBUG"/>
</configuration>

View File

@@ -35,6 +35,11 @@ public class ConfigDataRetryableConfigMapPropertySourceLocator extends ConfigMap
private ConfigMapPropertySourceLocator configMapPropertySourceLocator;
/**
* This constructor is deprecated, and we do not use it anymore internally. It will be
* removed in the next major release.
*/
@Deprecated(forRemoval = true)
public ConfigDataRetryableConfigMapPropertySourceLocator(
ConfigMapPropertySourceLocator configMapPropertySourceLocator, ConfigMapConfigProperties properties) {
super(properties);
@@ -45,6 +50,17 @@ public class ConfigDataRetryableConfigMapPropertySourceLocator extends ConfigMap
.build();
}
public ConfigDataRetryableConfigMapPropertySourceLocator(
ConfigMapPropertySourceLocator configMapPropertySourceLocator, ConfigMapConfigProperties properties,
ConfigMapCache cache) {
super(properties, cache);
this.configMapPropertySourceLocator = configMapPropertySourceLocator;
this.retryTemplate = RetryTemplate.builder().maxAttempts(properties.retry().maxAttempts())
.exponentialBackoff(properties.retry().initialInterval(), properties.retry().multiplier(),
properties.retry().maxInterval())
.build();
}
@Override
protected MapPropertySource getMapPropertySource(NormalizedSource normalizedSource,
ConfigurableEnvironment environment) {

View File

@@ -35,6 +35,11 @@ public class ConfigDataRetryableSecretsPropertySourceLocator extends SecretsProp
private SecretsPropertySourceLocator secretsPropertySourceLocator;
/**
* This constructor is deprecated, and we do not use it anymore internally. It will be
* removed in the next major release.
*/
@Deprecated(forRemoval = true)
public ConfigDataRetryableSecretsPropertySourceLocator(SecretsPropertySourceLocator propertySourceLocator,
SecretsConfigProperties secretsConfigProperties) {
super(secretsConfigProperties);
@@ -45,6 +50,16 @@ public class ConfigDataRetryableSecretsPropertySourceLocator extends SecretsProp
.build();
}
public ConfigDataRetryableSecretsPropertySourceLocator(SecretsPropertySourceLocator propertySourceLocator,
SecretsConfigProperties secretsConfigProperties, SecretsCache cache) {
super(secretsConfigProperties, cache);
this.secretsPropertySourceLocator = propertySourceLocator;
this.retryTemplate = RetryTemplate.builder().maxAttempts(properties.retry().maxAttempts())
.exponentialBackoff(properties.retry().initialInterval(), properties.retry().multiplier(),
properties.retry().maxInterval())
.build();
}
@Override
public PropertySource<?> locate(Environment environment) {
return retryTemplate.execute(retryContext -> secretsPropertySourceLocator.locate(environment));

View File

@@ -0,0 +1,41 @@
/*
* 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.commons.config;
/**
* @author wind57
*/
public interface ConfigMapCache {
/**
* Discards all stored entries from the cache.
*/
void discardAll();
/**
* an implementation that does nothing. In the next major release it will become
* absolute and must be removed.
*/
class NOOPCache implements ConfigMapCache {
@Override
public void discardAll() {
}
}
}

View File

@@ -51,10 +51,23 @@ public abstract class ConfigMapPropertySourceLocator implements PropertySourceLo
private static final Log LOG = LogFactory.getLog(ConfigMapPropertySourceLocator.class);
private final ConfigMapCache cache;
protected final ConfigMapConfigProperties properties;
/**
* This constructor is deprecated, and we do not use it anymore internally. It will be
* removed in the next major release.
*/
@Deprecated(forRemoval = true)
public ConfigMapPropertySourceLocator(ConfigMapConfigProperties properties) {
this.properties = properties;
this.cache = new ConfigMapCache.NOOPCache();
}
public ConfigMapPropertySourceLocator(ConfigMapConfigProperties properties, ConfigMapCache cache) {
this.properties = properties;
this.cache = cache;
}
protected abstract MapPropertySource getMapPropertySource(NormalizedSource normalizedSource,
@@ -73,6 +86,7 @@ public abstract class ConfigMapPropertySourceLocator implements PropertySourceLo
addPropertySourcesFromPaths(environment, composite);
cache.discardAll();
return composite;
}
return null;

View File

@@ -255,14 +255,6 @@ public final class ConfigUtils {
return new MultipleSourcesContainer(sourceNames, result);
}
public static boolean noSources(List<?> sources, String namespace) {
if (sources == null || sources.isEmpty()) {
LOG.debug("No sources in namespace '" + namespace + "'");
return true;
}
return false;
}
private static Map<String, String> decodeData(Map<String, String> data) {
Map<String, String> result = new HashMap<>(CollectionUtils.newHashMap(data.size()));
data.forEach((key, value) -> result.put(key, new String(Base64.getDecoder().decode(value)).trim()));

View File

@@ -0,0 +1,41 @@
/*
* 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.commons.config;
/**
* @author wind57
*/
public interface SecretsCache {
/**
* Discards all stored entries from the cache.
*/
void discardAll();
/**
* an implementation that does nothing. In the next major release it will become
* absolute and must be removed.
*/
class NOOPCache implements SecretsCache {
@Override
public void discardAll() {
}
}
}

View File

@@ -56,10 +56,23 @@ public abstract class SecretsPropertySourceLocator implements PropertySourceLoca
private static final Log LOG = LogFactory.getLog(SecretsPropertySourceLocator.class);
private final SecretsCache cache;
protected final SecretsConfigProperties properties;
/**
* This constructor is deprecated, and we do not use it anymore internally. It will be
* removed in the next major release.
*/
@Deprecated(forRemoval = true)
public SecretsPropertySourceLocator(SecretsConfigProperties properties) {
this.properties = properties;
this.cache = new SecretsCache.NOOPCache();
}
public SecretsPropertySourceLocator(SecretsConfigProperties properties, SecretsCache cache) {
this.properties = properties;
this.cache = cache;
}
@Override
@@ -77,6 +90,7 @@ public abstract class SecretsPropertySourceLocator implements PropertySourceLoca
uniqueSources.forEach(s -> composite.addPropertySource(getMapPropertySourceForSingleSecret(env, s)));
}
cache.discardAll();
return composite;
}
return null;

View File

@@ -63,7 +63,7 @@ public class Fabric8ConfigDataLocationResolver extends KubernetesConfigDataLocat
kubernetesClient, configMapProperties, namespaceProvider);
if (isRetryEnabledForConfigMap(configMapProperties)) {
configMapPropertySourceLocator = new ConfigDataRetryableConfigMapPropertySourceLocator(
configMapPropertySourceLocator, configMapProperties);
configMapPropertySourceLocator, configMapProperties, new Fabric8ConfigMapsCache());
}
registerSingle(bootstrapContext, ConfigMapPropertySourceLocator.class, configMapPropertySourceLocator,
@@ -75,7 +75,7 @@ public class Fabric8ConfigDataLocationResolver extends KubernetesConfigDataLocat
kubernetesClient, secretsProperties, namespaceProvider);
if (isRetryEnabledForSecrets(secretsProperties)) {
secretsPropertySourceLocator = new ConfigDataRetryableSecretsPropertySourceLocator(
secretsPropertySourceLocator, secretsProperties);
secretsPropertySourceLocator, secretsProperties, new Fabric8SecretsCache());
}
registerSingle(bootstrapContext, SecretsPropertySourceLocator.class, secretsPropertySourceLocator,

View File

@@ -45,7 +45,7 @@ public class Fabric8ConfigMapPropertySourceLocator extends ConfigMapPropertySour
Fabric8ConfigMapPropertySourceLocator(KubernetesClient client, ConfigMapConfigProperties properties,
KubernetesNamespaceProvider provider) {
super(properties);
super(properties, new Fabric8ConfigMapsCache());
this.client = client;
this.provider = provider;
}

View File

@@ -0,0 +1,74 @@
/*
* 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.fabric8.config;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import io.fabric8.kubernetes.api.model.ConfigMap;
import io.fabric8.kubernetes.client.KubernetesClient;
import org.apache.commons.logging.LogFactory;
import org.springframework.cloud.kubernetes.commons.config.ConfigMapCache;
import org.springframework.cloud.kubernetes.commons.config.StrippedSourceContainer;
import org.springframework.core.log.LogAccessor;
/**
* A cache of ConfigMaps per namespace. Makes sure we read config maps only once from a
* namespace.
*
* @author wind57
*/
final class Fabric8ConfigMapsCache implements ConfigMapCache {
private static final LogAccessor LOG = new LogAccessor(LogFactory.getLog(Fabric8ConfigMapsCache.class));
/**
* at the moment our loading of config maps is using a single thread, but might change
* in the future, thus a thread safe structure.
*/
private static final ConcurrentHashMap<String, List<StrippedSourceContainer>> CACHE = new ConcurrentHashMap<>();
@Override
public void discardAll() {
CACHE.clear();
}
static List<StrippedSourceContainer> byNamespace(KubernetesClient client, String namespace) {
boolean[] b = new boolean[1];
List<StrippedSourceContainer> result = CACHE.computeIfAbsent(namespace, x -> {
b[0] = true;
return strippedConfigMaps(client.configMaps().inNamespace(namespace).list().getItems());
});
if (b[0]) {
LOG.debug(() -> "Loaded all config maps in namespace '" + namespace + "'");
}
else {
LOG.debug(() -> "Loaded (from cache) all config maps in namespace '" + namespace + "'");
}
return result;
}
private static List<StrippedSourceContainer> strippedConfigMaps(List<ConfigMap> configMaps) {
return configMaps.stream().map(configMap -> new StrippedSourceContainer(configMap.getMetadata().getLabels(),
configMap.getMetadata().getName(), configMap.getData())).collect(Collectors.toList());
}
}

View File

@@ -20,10 +20,7 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import io.fabric8.kubernetes.api.model.ConfigMap;
import io.fabric8.kubernetes.api.model.Secret;
import io.fabric8.kubernetes.client.KubernetesClient;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
@@ -73,14 +70,11 @@ public final class Fabric8ConfigUtils {
*/
static MultipleSourcesContainer secretsDataByLabels(KubernetesClient client, String namespace,
Map<String, String> labels, Environment environment, Set<String> profiles) {
List<Secret> secrets = secretsSearch(client, namespace);
if (ConfigUtils.noSources(secrets, namespace)) {
List<StrippedSourceContainer> strippedSecrets = strippedSecrets(client, namespace);
if (strippedSecrets.isEmpty()) {
return MultipleSourcesContainer.empty();
}
List<StrippedSourceContainer> strippedSources = strippedSecrets(secrets);
return ConfigUtils.processLabeledData(strippedSources, environment, labels, namespace, profiles, true);
return ConfigUtils.processLabeledData(strippedSecrets, environment, labels, namespace, profiles, true);
}
/**
@@ -95,14 +89,12 @@ public final class Fabric8ConfigUtils {
*/
static MultipleSourcesContainer configMapsDataByLabels(KubernetesClient client, String namespace,
Map<String, String> labels, Environment environment, Set<String> profiles) {
List<ConfigMap> configMaps = configMapsSearch(client, namespace);
if (ConfigUtils.noSources(configMaps, namespace)) {
List<StrippedSourceContainer> strippedConfigMaps = strippedConfigMaps(client, namespace);
if (strippedConfigMaps.isEmpty()) {
return MultipleSourcesContainer.empty();
}
List<StrippedSourceContainer> strippedSources = strippedConfigMaps(configMaps);
return ConfigUtils.processLabeledData(strippedSources, environment, labels, namespace, profiles, false);
return ConfigUtils.processLabeledData(strippedConfigMaps, environment, labels, namespace, profiles, false);
}
/**
@@ -115,14 +107,11 @@ public final class Fabric8ConfigUtils {
*/
static MultipleSourcesContainer secretsDataByName(KubernetesClient client, String namespace,
LinkedHashSet<String> sourceNames, Environment environment) {
List<Secret> secrets = secretsSearch(client, namespace);
if (ConfigUtils.noSources(secrets, namespace)) {
List<StrippedSourceContainer> strippedSecrets = strippedSecrets(client, namespace);
if (strippedSecrets.isEmpty()) {
return MultipleSourcesContainer.empty();
}
List<StrippedSourceContainer> strippedSources = strippedSecrets(secrets);
return ConfigUtils.processNamedData(strippedSources, environment, sourceNames, namespace, true);
return ConfigUtils.processNamedData(strippedSecrets, environment, sourceNames, namespace, true);
}
/**
@@ -135,36 +124,29 @@ public final class Fabric8ConfigUtils {
*/
static MultipleSourcesContainer configMapsDataByName(KubernetesClient client, String namespace,
LinkedHashSet<String> sourceNames, Environment environment) {
List<ConfigMap> configMaps = configMapsSearch(client, namespace);
if (ConfigUtils.noSources(configMaps, namespace)) {
List<StrippedSourceContainer> strippedConfigMaps = strippedConfigMaps(client, namespace);
if (strippedConfigMaps.isEmpty()) {
return MultipleSourcesContainer.empty();
}
List<StrippedSourceContainer> strippedSources = strippedConfigMaps(configMaps);
return ConfigUtils.processNamedData(strippedSources, environment, sourceNames, namespace, false);
return ConfigUtils.processNamedData(strippedConfigMaps, environment, sourceNames, namespace, false);
}
// ******** non-exposed methods *******
private static List<StrippedSourceContainer> strippedConfigMaps(KubernetesClient client, String namespace) {
List<StrippedSourceContainer> strippedConfigMaps = Fabric8ConfigMapsCache.byNamespace(client, namespace);
if (strippedConfigMaps.isEmpty()) {
LOG.debug("No configmaps in namespace '" + namespace + "'");
}
private static List<Secret> secretsSearch(KubernetesClient client, String namespace) {
LOG.debug("Loading all secrets in namespace '" + namespace + "'");
return client.secrets().inNamespace(namespace).list().getItems();
return strippedConfigMaps;
}
private static List<ConfigMap> configMapsSearch(KubernetesClient client, String namespace) {
LOG.debug("Loading all config maps in namespace '" + namespace + "'");
return client.configMaps().inNamespace(namespace).list().getItems();
}
private static List<StrippedSourceContainer> strippedSecrets(KubernetesClient client, String namespace) {
List<StrippedSourceContainer> strippedSecrets = Fabric8SecretsCache.byNamespace(client, namespace);
if (strippedSecrets.isEmpty()) {
LOG.debug("No secrets in namespace '" + namespace + "'");
}
private static List<StrippedSourceContainer> strippedSecrets(List<Secret> secrets) {
return secrets.stream().map(secret -> new StrippedSourceContainer(secret.getMetadata().getLabels(),
secret.getMetadata().getName(), secret.getData())).collect(Collectors.toList());
}
private static List<StrippedSourceContainer> strippedConfigMaps(List<ConfigMap> configMaps) {
return configMaps.stream().map(configMap -> new StrippedSourceContainer(configMap.getMetadata().getLabels(),
configMap.getMetadata().getName(), configMap.getData())).collect(Collectors.toList());
return strippedSecrets;
}
}

View File

@@ -0,0 +1,74 @@
/*
* 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.fabric8.config;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import io.fabric8.kubernetes.api.model.Secret;
import io.fabric8.kubernetes.client.KubernetesClient;
import org.apache.commons.logging.LogFactory;
import org.springframework.cloud.kubernetes.commons.config.SecretsCache;
import org.springframework.cloud.kubernetes.commons.config.StrippedSourceContainer;
import org.springframework.core.log.LogAccessor;
/**
* A cache of ConfigMaps per namespace. Makes sure we read config maps only once from a
* namespace.
*
* @author wind57
*/
final class Fabric8SecretsCache implements SecretsCache {
private static final LogAccessor LOG = new LogAccessor(LogFactory.getLog(Fabric8SecretsCache.class));
/**
* at the moment our loading of config maps is using a single thread, but might change
* in the future, thus a thread safe structure.
*/
private static final ConcurrentHashMap<String, List<StrippedSourceContainer>> CACHE = new ConcurrentHashMap<>();
@Override
public void discardAll() {
CACHE.clear();
}
static List<StrippedSourceContainer> byNamespace(KubernetesClient client, String namespace) {
boolean[] b = new boolean[1];
List<StrippedSourceContainer> result = CACHE.computeIfAbsent(namespace, x -> {
b[0] = true;
return strippedSecrets(client.secrets().inNamespace(namespace).list().getItems());
});
if (b[0]) {
LOG.debug(() -> "Loaded all secrets in namespace '" + namespace + "'");
}
else {
LOG.debug(() -> "Loaded (from cache) all secrets in namespace '" + namespace + "'");
}
return result;
}
private static List<StrippedSourceContainer> strippedSecrets(List<Secret> secrets) {
return secrets.stream().map(secret -> new StrippedSourceContainer(secret.getMetadata().getLabels(),
secret.getMetadata().getName(), secret.getData())).collect(Collectors.toList());
}
}

View File

@@ -45,7 +45,7 @@ public class Fabric8SecretsPropertySourceLocator extends SecretsPropertySourceLo
Fabric8SecretsPropertySourceLocator(KubernetesClient client, SecretsConfigProperties properties,
KubernetesNamespaceProvider provider) {
super(properties);
super(properties, new Fabric8SecretsCache());
this.client = client;
this.provider = provider;
}

View File

@@ -23,6 +23,7 @@ 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.server.mock.EnableKubernetesMockClient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.kubernetes.commons.config.NamedConfigMapNormalizedSource;
@@ -39,6 +40,11 @@ class ConfigMapsTest {
private static KubernetesClient mockClient;
@AfterEach
void afterEach() {
new Fabric8ConfigMapsCache().discardAll();
}
@Test
public void testConfigMapList() {
mockClient.configMaps().inNamespace("ns1")

View File

@@ -19,6 +19,7 @@ package org.springframework.cloud.kubernetes.fabric8.config;
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.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.kubernetes.commons.config.ConfigUtils;
@@ -41,6 +42,11 @@ class Fabric8ConfigMapPropertySourceTests {
private static final ConfigUtils.Prefix DEFAULT = ConfigUtils.findPrefix("default", false, false, "irrelevant");
@AfterEach
void afterEach() {
new Fabric8ConfigMapsCache().discardAll();
}
@Test
void constructorShouldThrowExceptionOnFailureWhenFailFastIsEnabled() {
String name = "my-config";

View File

@@ -27,6 +27,7 @@ import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
import io.fabric8.kubernetes.api.model.SecretBuilder;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -43,6 +44,12 @@ class Fabric8ConfigUtilsTests {
private KubernetesClient client;
@AfterEach
void afterEach() {
new Fabric8ConfigMapsCache().discardAll();
new Fabric8SecretsCache().discardAll();
}
// secret "my-secret" is deployed without any labels; we search for it by labels
// "color=red" and do not find it.
@Test

View File

@@ -30,7 +30,10 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
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.config.ConfigUtils;
import org.springframework.cloud.kubernetes.commons.config.LabeledConfigMapNormalizedSource;
import org.springframework.cloud.kubernetes.commons.config.NormalizedSource;
@@ -41,6 +44,7 @@ import org.springframework.mock.env.MockEnvironment;
* @author wind57
*/
@EnableKubernetesMockClient(crud = true, https = false)
@ExtendWith(OutputCaptureExtension.class)
class LabeledConfigMapContextToSourceDataProviderTests {
private static final String NAMESPACE = "default";
@@ -76,6 +80,7 @@ class LabeledConfigMapContextToSourceDataProviderTests {
@AfterEach
void afterEach() {
mockClient.configMaps().inNamespace(NAMESPACE).delete();
new Fabric8ConfigMapsCache().discardAll();
}
/**
@@ -411,4 +416,56 @@ class LabeledConfigMapContextToSourceDataProviderTests {
}
/**
* <pre>
* - configmap "red-configmap" with label "{color:red}"
* - configmap "green-configmap" with labels "{color:green}"
* - we first search for "red" and find it, and it is retrieved from the cluster via the client.
* - we then search for the "green" one, and it is retrieved from the cache this time.
* </pre>
*/
@Test
void cache(CapturedOutput output) {
ConfigMap redConfigMap = new ConfigMapBuilder().withNewMetadata().withName("red-configmap")
.withLabels(Collections.singletonMap("color", "red")).endMetadata().addToData("one", "1").build();
ConfigMap greenConfigmap = new ConfigMapBuilder().withNewMetadata().withName("green-configmap")
.withLabels(Map.of("color", "green")).endMetadata().addToData("two", "2").build();
mockClient.configMaps().inNamespace(NAMESPACE).resource(redConfigMap).create();
mockClient.configMaps().inNamespace(NAMESPACE).resource(greenConfigmap).create();
MockEnvironment environment = new MockEnvironment();
NormalizedSource redNormalizedSource = new LabeledConfigMapNormalizedSource(NAMESPACE,
Collections.singletonMap("color", "red"), true, ConfigUtils.Prefix.DELAYED, true);
Fabric8ConfigContext redContext = new Fabric8ConfigContext(mockClient, redNormalizedSource, NAMESPACE,
environment);
Fabric8ContextToSourceData redData = new LabeledConfigMapContextToSourceDataProvider().get();
SourceData redSourceData = redData.apply(redContext);
Assertions.assertEquals(redSourceData.sourceData().size(), 1);
Assertions.assertEquals(redSourceData.sourceData().get("red-configmap.one"), "1");
Assertions.assertTrue(output.getAll().contains("Loaded all config maps in namespace '" + NAMESPACE + "'"));
NormalizedSource greenNormalizedSource = new LabeledConfigMapNormalizedSource(NAMESPACE,
Collections.singletonMap("color", "green"), true, ConfigUtils.Prefix.DELAYED, true);
Fabric8ConfigContext greenContext = new Fabric8ConfigContext(mockClient, greenNormalizedSource, NAMESPACE,
environment);
Fabric8ContextToSourceData greenData = new LabeledConfigMapContextToSourceDataProvider().get();
SourceData greenSourceData = greenData.apply(greenContext);
Assertions.assertEquals(greenSourceData.sourceData().size(), 1);
Assertions.assertEquals(greenSourceData.sourceData().get("green-configmap.two"), "2");
// meaning there is a single entry with such a log statement
String[] out = output.getAll().split("Loaded all config maps in namespace");
Assertions.assertEquals(out.length, 2);
// meaning that the second read was done from the cache
out = output.getAll().split("Loaded \\(from cache\\) all config maps in namespace");
Assertions.assertEquals(out.length, 2);
}
}

View File

@@ -31,7 +31,10 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
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.config.ConfigUtils;
import org.springframework.cloud.kubernetes.commons.config.LabeledSecretNormalizedSource;
import org.springframework.cloud.kubernetes.commons.config.NormalizedSource;
@@ -44,6 +47,7 @@ import org.springframework.mock.env.MockEnvironment;
* @author wind57
*/
@EnableKubernetesMockClient(crud = true, https = false)
@ExtendWith(OutputCaptureExtension.class)
class LabeledSecretContextToSourceDataProviderTests {
private static final String NAMESPACE = "default";
@@ -79,6 +83,7 @@ class LabeledSecretContextToSourceDataProviderTests {
@AfterEach
void afterEach() {
mockClient.secrets().inNamespace(NAMESPACE).delete();
new Fabric8SecretsCache().discardAll();
}
/**
@@ -446,4 +451,57 @@ class LabeledSecretContextToSourceDataProviderTests {
Assertions.assertEquals(sourceData.sourceName(), "secret.color-secret.default");
}
/**
* <pre>
* - secret "red" with label "{color:red}"
* - secret "green" with labels "{color:green}"
* - we first search for "red" and find it, and it is retrieved from the cluster via the client.
* - we then search for the "green" one, and it is retrieved from the cache this time.
* </pre>
*/
@Test
void cache(CapturedOutput output) {
Secret red = new SecretBuilder().withNewMetadata().withName("red")
.withLabels(Collections.singletonMap("color", "red")).endMetadata()
.addToData("one", Base64.getEncoder().encodeToString("1".getBytes())).build();
Secret green = new SecretBuilder().withNewMetadata().withName("green").withLabels(Map.of("color", "green"))
.endMetadata().addToData("two", Base64.getEncoder().encodeToString("2".getBytes())).build();
mockClient.secrets().inNamespace(NAMESPACE).resource(red).create();
mockClient.secrets().inNamespace(NAMESPACE).resource(green).create();
MockEnvironment environment = new MockEnvironment();
NormalizedSource redNormalizedSource = new LabeledSecretNormalizedSource(NAMESPACE,
Collections.singletonMap("color", "red"), true, ConfigUtils.Prefix.DELAYED, true);
Fabric8ConfigContext redContext = new Fabric8ConfigContext(mockClient, redNormalizedSource, NAMESPACE,
environment);
Fabric8ContextToSourceData redData = new LabeledSecretContextToSourceDataProvider().get();
SourceData redSourceData = redData.apply(redContext);
Assertions.assertEquals(redSourceData.sourceData().size(), 1);
Assertions.assertEquals(redSourceData.sourceData().get("red.one"), "1");
Assertions.assertTrue(output.getAll().contains("Loaded all secrets in namespace '" + NAMESPACE + "'"));
NormalizedSource greenNormalizedSource = new LabeledSecretNormalizedSource(NAMESPACE,
Collections.singletonMap("color", "green"), true, ConfigUtils.Prefix.DELAYED, true);
Fabric8ConfigContext greenContext = new Fabric8ConfigContext(mockClient, greenNormalizedSource, NAMESPACE,
environment);
Fabric8ContextToSourceData greenData = new LabeledSecretContextToSourceDataProvider().get();
SourceData greenSourceData = greenData.apply(greenContext);
Assertions.assertEquals(greenSourceData.sourceData().size(), 1);
Assertions.assertEquals(greenSourceData.sourceData().get("green.two"), "2");
// meaning there is a single entry with such a log statement
String[] out = output.getAll().split("Loaded all secrets in namespace");
Assertions.assertEquals(out.length, 2);
// meaning that the second read was done from the cache
out = output.getAll().split("Loaded \\(from cache\\) all secrets in namespace");
Assertions.assertEquals(out.length, 2);
}
}

View File

@@ -28,7 +28,10 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
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.config.ConfigUtils;
import org.springframework.cloud.kubernetes.commons.config.NamedConfigMapNormalizedSource;
import org.springframework.cloud.kubernetes.commons.config.NormalizedSource;
@@ -41,6 +44,7 @@ import org.springframework.mock.env.MockEnvironment;
* @author wind57
*/
@EnableKubernetesMockClient(crud = true, https = false)
@ExtendWith(OutputCaptureExtension.class)
class NamedConfigMapContextToSourceDataProviderTests {
private static final String NAMESPACE = "default";
@@ -67,6 +71,7 @@ class NamedConfigMapContextToSourceDataProviderTests {
@AfterEach
void afterEach() {
mockClient.configMaps().inNamespace(NAMESPACE).delete();
new Fabric8ConfigMapsCache().discardAll();
}
/**
@@ -340,4 +345,55 @@ class NamedConfigMapContextToSourceDataProviderTests {
Assertions.assertEquals(sourceData.sourceData(), Collections.singletonMap("key", "value"));
}
/**
* <pre>
* - two configmaps are deployed : "red", "green", in the same namespace.
* - we first search for "red" and find it, and it is retrieved from the cluster via the client.
* - we then search for the "green" one, and it is retrieved from the cache this time.
* </pre>
*/
@Test
void cache(CapturedOutput output) {
ConfigMap red = new ConfigMapBuilder().withNewMetadata().withName("red").endMetadata()
.addToData(COLOR_REALLY_RED).build();
ConfigMap green = new ConfigMapBuilder().withNewMetadata().withName("green").endMetadata()
.addToData("taste", "mango").build();
mockClient.configMaps().inNamespace(NAMESPACE).resource(red).create();
mockClient.configMaps().inNamespace(NAMESPACE).resource(green).create();
MockEnvironment env = new MockEnvironment();
NormalizedSource redNormalizedSource = new NamedConfigMapNormalizedSource("red", NAMESPACE, true, PREFIX,
false);
Fabric8ConfigContext redContext = new Fabric8ConfigContext(mockClient, redNormalizedSource, NAMESPACE, env);
Fabric8ContextToSourceData redData = new NamedConfigMapContextToSourceDataProvider().get();
SourceData redSourceData = redData.apply(redContext);
Assertions.assertEquals(redSourceData.sourceName(), "configmap.red.default");
Assertions.assertEquals(redSourceData.sourceData().size(), 1);
Assertions.assertEquals(redSourceData.sourceData().get("some.color"), "really-red");
Assertions.assertTrue(output.getAll().contains("Loaded all config maps in namespace '" + NAMESPACE + "'"));
NormalizedSource greenNormalizedSource = new NamedConfigMapNormalizedSource("green", NAMESPACE, true, PREFIX,
false);
Fabric8ConfigContext greenContext = new Fabric8ConfigContext(mockClient, greenNormalizedSource, NAMESPACE, env);
Fabric8ContextToSourceData greenData = new NamedConfigMapContextToSourceDataProvider().get();
SourceData greenSourceData = greenData.apply(greenContext);
Assertions.assertEquals(greenSourceData.sourceName(), "configmap.green.default");
Assertions.assertEquals(greenSourceData.sourceData().size(), 1);
Assertions.assertEquals(greenSourceData.sourceData().get("some.taste"), "mango");
// meaning there is a single entry with such a log statement
String[] out = output.getAll().split("Loaded all config maps in namespace");
Assertions.assertEquals(out.length, 2);
// meaning that the second read was done from the cache
out = output.getAll().split("Loaded \\(from cache\\) all config maps in namespace");
Assertions.assertEquals(out.length, 2);
}
}

View File

@@ -29,7 +29,10 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
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.config.ConfigUtils;
import org.springframework.cloud.kubernetes.commons.config.NamedSecretNormalizedSource;
import org.springframework.cloud.kubernetes.commons.config.NormalizedSource;
@@ -42,6 +45,7 @@ import org.springframework.mock.env.MockEnvironment;
* @author wind57
*/
@EnableKubernetesMockClient(crud = true, https = false)
@ExtendWith(OutputCaptureExtension.class)
class NamedSecretContextToSourceDataProviderTests {
private static final String NAMESPACE = "default";
@@ -66,6 +70,7 @@ class NamedSecretContextToSourceDataProviderTests {
@AfterEach
void afterEach() {
mockClient.secrets().inNamespace(NAMESPACE).delete();
new Fabric8SecretsCache().discardAll();
}
/**
@@ -306,4 +311,54 @@ class NamedSecretContextToSourceDataProviderTests {
Assertions.assertEquals(sourceData.sourceData(), Collections.singletonMap("key", "value"));
}
/**
* <pre>
* - two secrets are deployed : "red", "green", in the same namespace.
* - we first search for "red" and find it, and it is retrieved from the cluster via the client.
* - we then search for the "green" one, and it is retrieved from the cache this time.
* </pre>
*/
@Test
void cache(CapturedOutput output) {
Secret red = new SecretBuilder().withNewMetadata().withName("red").endMetadata()
.addToData("color", Base64.getEncoder().encodeToString("red".getBytes())).build();
Secret green = new SecretBuilder().withNewMetadata().withName("green").endMetadata()
.addToData("taste", Base64.getEncoder().encodeToString("mango".getBytes())).build();
mockClient.secrets().inNamespace(NAMESPACE).resource(red).create();
mockClient.secrets().inNamespace(NAMESPACE).resource(green).create();
MockEnvironment env = new MockEnvironment();
NormalizedSource redNormalizedSource = new NamedSecretNormalizedSource("red", NAMESPACE, true, PREFIX, false);
Fabric8ConfigContext redContext = new Fabric8ConfigContext(mockClient, redNormalizedSource, NAMESPACE, env);
Fabric8ContextToSourceData redData = new NamedSecretContextToSourceDataProvider().get();
SourceData redSourceData = redData.apply(redContext);
Assertions.assertEquals(redSourceData.sourceName(), "secret.red.default");
Assertions.assertEquals(redSourceData.sourceData().size(), 1);
Assertions.assertEquals(redSourceData.sourceData().get("some.color"), "red");
Assertions.assertTrue(output.getAll().contains("Loaded all secrets in namespace '" + NAMESPACE + "'"));
NormalizedSource greenNormalizedSource = new NamedSecretNormalizedSource("green", NAMESPACE, true, PREFIX,
false);
Fabric8ConfigContext greenContext = new Fabric8ConfigContext(mockClient, greenNormalizedSource, NAMESPACE, env);
Fabric8ContextToSourceData greenData = new NamedSecretContextToSourceDataProvider().get();
SourceData greenSourceData = greenData.apply(greenContext);
Assertions.assertEquals(greenSourceData.sourceName(), "secret.green.default");
Assertions.assertEquals(greenSourceData.sourceData().size(), 1);
Assertions.assertEquals(greenSourceData.sourceData().get("some.taste"), "mango");
// meaning there is a single entry with such a log statement
String[] out = output.getAll().split("Loaded all secrets in namespace");
Assertions.assertEquals(out.length, 2);
// meaning that the second read was done from the cache
out = output.getAll().split("Loaded \\(from cache\\) all secrets in namespace");
Assertions.assertEquals(out.length, 2);
}
}

View File

@@ -4,4 +4,6 @@
<logger name="org.hibernate.validator"
level="info"/> <!-- Validator prints a lot of debug messages during integration tests -->
<logger name="okhttp3.mockwebserver" level="debug"/>
<!-- needed for CapturedOutput -->
<logger name="org.springframework.cloud.kubernetes.fabric8.config" level="DEBUG"/>
</configuration>