diff --git a/spring/pom.xml b/spring/pom.xml index d17abc03cc..5de98b083f 100644 --- a/spring/pom.xml +++ b/spring/pom.xml @@ -64,6 +64,11 @@ wiremock test + + org.awaitility + awaitility + test + diff --git a/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/KubernetesFromConfigMapProcessor.java b/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/KubernetesFromConfigMapProcessor.java new file mode 100644 index 0000000000..9b0dc4ac00 --- /dev/null +++ b/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/KubernetesFromConfigMapProcessor.java @@ -0,0 +1,164 @@ +/* +Copyright 2021 The Kubernetes 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 +http://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 io.kubernetes.client.spring.extended.manifests; + +import com.github.benmanes.caffeine.cache.CacheLoader; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; +import io.kubernetes.client.openapi.models.V1ConfigMap; +import io.kubernetes.client.spring.extended.manifests.annotation.FromConfigMap; +import io.kubernetes.client.spring.extended.manifests.config.KubernetesManifestsProperties; +import io.kubernetes.client.spring.extended.manifests.configmaps.ConfigMapGetter; +import java.lang.reflect.Field; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.beans.factory.config.InstantiationAwareBeanPostProcessor; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.util.ReflectionUtils; + +public class KubernetesFromConfigMapProcessor + implements InstantiationAwareBeanPostProcessor, BeanPostProcessor, ApplicationContextAware { + + private static final Logger log = LoggerFactory.getLogger(KubernetesFromConfigMapProcessor.class); + + private ApplicationContext applicationContext; + + private final ScheduledExecutorService configMapKeyRefresher = + Executors.newSingleThreadScheduledExecutor(); + + @Autowired private KubernetesManifestsProperties manifestsProperties; + + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + + for (Field field : bean.getClass().getDeclaredFields()) { + ReflectionUtils.makeAccessible(field); + try { + if (field.get(bean) != null) { + continue; // field already set, skip processing + } + } catch (IllegalAccessException e) { + log.warn("Failed inject resource for @FromConfigMap annotated field {}", field, e); + continue; + } + + FromConfigMap fromConfigMapAnnotation = field.getAnnotation(FromConfigMap.class); + if (fromConfigMapAnnotation == null) { + continue; // skip if the field doesn't have the annotation + } + + if (!Map.class.isAssignableFrom(field.getType())) { + log.warn( + "Failed inject resource for @FromConfigMap annotated field {}, the declaring type should be Map", + field); + continue; + } + + ConfigMapGetter configMapGetter = + getOrCreateConfigMapGetter(fromConfigMapAnnotation, applicationContext); + + LoadingCache configMapDataCache = + Caffeine.newBuilder() + .expireAfterWrite(manifestsProperties.getRefreshInterval()) + .build( + new ConfigMapGetterCacheLoader( + () -> { + return configMapGetter.get( + fromConfigMapAnnotation.namespace(), fromConfigMapAnnotation.name()); + })); + fullyRefreshCache(configMapGetter, fromConfigMapAnnotation, configMapDataCache); + configMapKeyRefresher.scheduleAtFixedRate( + () -> { + fullyRefreshCache(configMapGetter, fromConfigMapAnnotation, configMapDataCache); + }, + manifestsProperties.getRefreshInterval().getSeconds(), + manifestsProperties.getRefreshInterval().getSeconds(), + TimeUnit.SECONDS); + ReflectionUtils.setField(field, bean, configMapDataCache.asMap()); + } + + return bean; + } + + private static void fullyRefreshCache( + ConfigMapGetter configMapGetter, + FromConfigMap fromConfigMapAnnotation, + LoadingCache configMapDataCache) { + V1ConfigMap configMap = + configMapGetter.get(fromConfigMapAnnotation.namespace(), fromConfigMapAnnotation.name()); + if (configMap == null || configMap.getData() == null) { + return; + } + // TODO: make the cache data refreshment atomic + configMap.getData().keySet().stream().forEach(key -> configMapDataCache.refresh(key)); + } + + private ConfigMapGetter getOrCreateConfigMapGetter( + FromConfigMap fromConfigMapAnnotation, ApplicationContext applicationContext) { + ConfigMapGetter configMapGetter; + try { + configMapGetter = + applicationContext + .getAutowireCapableBeanFactory() + .getBean(fromConfigMapAnnotation.configMapGetter()); + } catch (NoSuchBeanDefinitionException ne) { + try { + configMapGetter = fromConfigMapAnnotation.configMapGetter().newInstance(); + } catch (IllegalAccessException | InstantiationException e) { + throw new BeanCreationException("failed creating configmap getter instance", e); + } + applicationContext.getAutowireCapableBeanFactory().autowireBean(configMapGetter); + applicationContext + .getAutowireCapableBeanFactory() + .initializeBean( + configMapGetter, + "configmap-getter-" + fromConfigMapAnnotation.configMapGetter().getSimpleName()); + } + return configMapGetter; + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } + + static class ConfigMapGetterCacheLoader implements CacheLoader { + + ConfigMapGetterCacheLoader(Supplier configMapSupplier) { + this.configMapSupplier = configMapSupplier; + } + + private final Supplier configMapSupplier; + + @Override + public @Nullable String load(@NonNull String key) throws Exception { + V1ConfigMap configMap = this.configMapSupplier.get(); + if (configMap == null || configMap.getData() == null) { + return null; + } + return configMap.getData().get(key); + } + } +} diff --git a/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/annotation/FromConfigMap.java b/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/annotation/FromConfigMap.java new file mode 100644 index 0000000000..240bee9aa1 --- /dev/null +++ b/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/annotation/FromConfigMap.java @@ -0,0 +1,56 @@ +/* +Copyright 2021 The Kubernetes 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 +http://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 io.kubernetes.client.spring.extended.manifests.annotation; + +import io.kubernetes.client.spring.extended.manifests.configmaps.ConfigMapGetter; +import io.kubernetes.client.spring.extended.manifests.configmaps.PollingConfigMapGetter; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Injecting resources by reading from ConfigMap. + * + *

The annotations has to be be applied to member field of type Map. + * + *

The content of the map will be automatically updated at the interval specified by the property + * "kubernetes.manifests.refreshInterval". + * + *

If the given configmap, is not present in the cluster, the content of the map will stay empty. + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface FromConfigMap { + + /** + * Namespace of the configmap. + * + * @return the string + */ + String namespace(); + + /** + * Name of the configmap + * + * @return the string + */ + String name(); + + /** + * Config map getter class. + * + * @return the class + */ + Class configMapGetter() default PollingConfigMapGetter.class; +} diff --git a/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/config/KubernetesManifestsAutoConfiguration.java b/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/config/KubernetesManifestsAutoConfiguration.java index 6db6496c32..011a782881 100644 --- a/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/config/KubernetesManifestsAutoConfiguration.java +++ b/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/config/KubernetesManifestsAutoConfiguration.java @@ -12,27 +12,38 @@ */ package io.kubernetes.client.spring.extended.manifests.config; +import io.kubernetes.client.spring.extended.manifests.KubernetesFromConfigMapProcessor; import io.kubernetes.client.spring.extended.manifests.KubernetesFromYamlProcessor; import io.kubernetes.client.spring.extended.manifests.KubernetesKubectlApplyProcessor; import io.kubernetes.client.spring.extended.manifests.KubernetesKubectlCreateProcessor; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration(proxyBeanMethods = false) @ConditionalOnKubernetesManifestsEnabled +@EnableConfigurationProperties({ + KubernetesManifestsProperties.class, +}) public class KubernetesManifestsAutoConfiguration { @Bean @ConditionalOnMissingBean - public KubernetesKubectlCreateProcessor kubernetesKubectlCreateProcessor() { - return new KubernetesKubectlCreateProcessor(); + public KubernetesFromYamlProcessor kubernetesFromYamlProcessor() { + return new KubernetesFromYamlProcessor(); } @Bean @ConditionalOnMissingBean - public KubernetesFromYamlProcessor kubernetesFromYamlProcessor() { - return new KubernetesFromYamlProcessor(); + public KubernetesFromConfigMapProcessor kubernetesFromConfigMapProcessor() { + return new KubernetesFromConfigMapProcessor(); + } + + @Bean + @ConditionalOnMissingBean + public KubernetesKubectlCreateProcessor kubernetesKubectlCreateProcessor() { + return new KubernetesKubectlCreateProcessor(); } @Bean diff --git a/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/config/KubernetesManifestsProperties.java b/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/config/KubernetesManifestsProperties.java new file mode 100644 index 0000000000..ec5c8bd8b0 --- /dev/null +++ b/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/config/KubernetesManifestsProperties.java @@ -0,0 +1,30 @@ +/* +Copyright 2021 The Kubernetes 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 +http://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 io.kubernetes.client.spring.extended.manifests.config; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("kubernetes.manifests") +public class KubernetesManifestsProperties { + private Duration refreshInterval = Duration.ofSeconds(5); + + public Duration getRefreshInterval() { + return refreshInterval; + } + + public KubernetesManifestsProperties setRefreshInterval(Duration refreshInterval) { + this.refreshInterval = refreshInterval; + return this; + } +} diff --git a/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/configmaps/ConfigMapGetter.java b/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/configmaps/ConfigMapGetter.java new file mode 100644 index 0000000000..ce77616f3b --- /dev/null +++ b/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/configmaps/ConfigMapGetter.java @@ -0,0 +1,19 @@ +/* +Copyright 2021 The Kubernetes 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 +http://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 io.kubernetes.client.spring.extended.manifests.configmaps; + +import io.kubernetes.client.openapi.models.V1ConfigMap; + +public interface ConfigMapGetter { + V1ConfigMap get(String namespace, String name); +} diff --git a/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/configmaps/InformerConfigMapGetter.java b/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/configmaps/InformerConfigMapGetter.java new file mode 100644 index 0000000000..140422ce55 --- /dev/null +++ b/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/configmaps/InformerConfigMapGetter.java @@ -0,0 +1,27 @@ +/* +Copyright 2021 The Kubernetes 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 +http://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 io.kubernetes.client.spring.extended.manifests.configmaps; + +import io.kubernetes.client.informer.cache.Lister; +import io.kubernetes.client.openapi.models.V1ConfigMap; +import org.springframework.beans.factory.annotation.Autowired; + +public class InformerConfigMapGetter implements ConfigMapGetter { + + @Autowired private Lister configMapLister; + + @Override + public V1ConfigMap get(String namespace, String name) { + return this.configMapLister.namespace(namespace).get(name); + } +} diff --git a/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/configmaps/PollingConfigMapGetter.java b/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/configmaps/PollingConfigMapGetter.java new file mode 100644 index 0000000000..34d0ee9840 --- /dev/null +++ b/spring/src/main/java/io/kubernetes/client/spring/extended/manifests/configmaps/PollingConfigMapGetter.java @@ -0,0 +1,45 @@ +/* +Copyright 2020 The Kubernetes 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 +http://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 io.kubernetes.client.spring.extended.manifests.configmaps; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import io.kubernetes.client.apimachinery.NamespaceName; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1ConfigMap; +import java.time.Duration; +import org.springframework.beans.factory.annotation.Autowired; + +public class PollingConfigMapGetter implements ConfigMapGetter { + + private static final Cache lastObservedConfigMap = + Caffeine.newBuilder().expireAfterWrite(Duration.ofMinutes(5)).build(); + + @Autowired private ApiClient apiClient; + + @Override + public V1ConfigMap get(String namespace, String name) { + CoreV1Api coreV1Api = new CoreV1Api(apiClient); + return lastObservedConfigMap.get( + new NamespaceName(namespace, name), + k -> { + try { + return coreV1Api.readNamespacedConfigMap(name, namespace, null, null, null); + } catch (ApiException e) { + throw new IllegalStateException(e); + } + }); + } +} diff --git a/spring/src/test/java/io/kubernetes/client/spring/extended/manifests/KubernetesFromConfigMapTest.java b/spring/src/test/java/io/kubernetes/client/spring/extended/manifests/KubernetesFromConfigMapTest.java new file mode 100644 index 0000000000..ca83a33da7 --- /dev/null +++ b/spring/src/test/java/io/kubernetes/client/spring/extended/manifests/KubernetesFromConfigMapTest.java @@ -0,0 +1,153 @@ +/* +Copyright 2021 The Kubernetes 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 +http://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 io.kubernetes.client.spring.extended.manifests; + +import static junit.framework.Assert.assertNull; +import static junit.framework.TestCase.assertEquals; +import static junit.framework.TestCase.assertNotNull; + +import io.kubernetes.client.openapi.models.V1ConfigMap; +import io.kubernetes.client.spring.extended.manifests.annotation.FromConfigMap; +import io.kubernetes.client.spring.extended.manifests.config.KubernetesManifestsProperties; +import io.kubernetes.client.spring.extended.manifests.configmaps.ConfigMapGetter; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.awaitility.Awaitility; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runner.RunWith; +import org.junit.runners.model.Statement; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.junit4.SpringRunner; + +@RunWith(SpringRunner.class) +@SpringBootTest( + classes = KubernetesFromConfigMapTest.App.class, + properties = { + "kubernetes.manifests.refreshInterval=1s", + }) +public class KubernetesFromConfigMapTest { + + @Rule public ConfigMapResetter configMapResetter = new ConfigMapResetter(); + + @SpringBootConfiguration + @EnableAutoConfiguration + static class App { + + @Bean + public KubernetesFromConfigMapProcessor kubernetesFromConfigMapProcessor() { + return new KubernetesFromConfigMapProcessor(); + } + + @Bean + public MockAtomicConfigMapGetter mockAtomicConfigMapGetter() { + MockAtomicConfigMapGetter atomicConfigMapGetter = new MockAtomicConfigMapGetter(); + return atomicConfigMapGetter; + } + + @Bean + public KubernetesFromConfigMapTest.MyBean myBean() { + return new KubernetesFromConfigMapTest.MyBean(); + } + } + + static class MyBean { + @FromConfigMap(namespace = "default", name = "foo", configMapGetter = MockConfigMapGetter.class) + private Map staticData; + + @FromConfigMap( + namespace = "default", + name = "foo", + configMapGetter = MockAtomicConfigMapGetter.class) + private Map dynamicData; + } + + @Autowired private KubernetesFromConfigMapTest.MyBean myBean; + + @Autowired private MockAtomicConfigMapGetter mockAtomicConfigMapGetter; + + @Autowired private KubernetesManifestsProperties manifestsProperties; + + @Test + public void testReadOnce() { + assertNotNull(myBean.staticData); + assertEquals("bar", myBean.staticData.get("foo")); + } + + @Test + public void testValueUpdate() throws InterruptedException { + assertEquals(Duration.ofSeconds(1), manifestsProperties.getRefreshInterval()); + assertNotNull(myBean.dynamicData); + assertEquals("bar1", myBean.dynamicData.get("foo")); + mockAtomicConfigMapGetter.configMapAtomicReference.set( + new V1ConfigMap().putDataItem("foo", "bar2")); + Thread.sleep(manifestsProperties.getRefreshInterval().toMillis()); + assertEquals("bar2", myBean.dynamicData.get("foo")); + } + + @Test + public void testKeyUpdate() throws InterruptedException { + assertEquals(Duration.ofSeconds(1), manifestsProperties.getRefreshInterval()); + assertNotNull(myBean.dynamicData); + assertEquals("bar1", myBean.dynamicData.get("foo")); + mockAtomicConfigMapGetter.configMapAtomicReference.set( + new V1ConfigMap().putDataItem("foo1", "bar")); + Thread.sleep(manifestsProperties.getRefreshInterval().toMillis()); + assertNull(myBean.dynamicData.get("foo")); // old key should be removed + assertEquals("bar", myBean.dynamicData.get("foo1")); // new key should be added + } + + private void reset() { + mockAtomicConfigMapGetter.configMapAtomicReference.set( + new V1ConfigMap().putDataItem("foo", "bar1")); + } + + static class MockConfigMapGetter implements ConfigMapGetter { + @Override + public V1ConfigMap get(String namespace, String name) { + return new V1ConfigMap().putDataItem("foo", "bar"); + } + } + + static class MockAtomicConfigMapGetter implements ConfigMapGetter { + + private final AtomicReference configMapAtomicReference = new AtomicReference<>(); + + @Override + public V1ConfigMap get(String namespace, String name) { + return configMapAtomicReference.get(); + } + } + + class ConfigMapResetter implements TestRule { + @Override + public Statement apply(Statement statement, Description description) { + return new Statement() { + @Override + public void evaluate() throws Throwable { + mockAtomicConfigMapGetter.configMapAtomicReference.set( + new V1ConfigMap().putDataItem("foo", "bar1")); + Awaitility.await().until(() -> "bar1".equals(myBean.dynamicData.get("foo"))); + statement.evaluate(); + } + }; + } + } +}