|
| 1 | +/* |
| 2 | + * Copyright 2013-2024 the original author or authors. |
| 3 | + * |
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | + * you may not use this file except in compliance with the License. |
| 6 | + * You may obtain a copy of the License at |
| 7 | + * |
| 8 | + * https://www.apache.org/licenses/LICENSE-2.0 |
| 9 | + * |
| 10 | + * Unless required by applicable law or agreed to in writing, software |
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | + * See the License for the specific language governing permissions and |
| 14 | + * limitations under the License. |
| 15 | + */ |
| 16 | + |
| 17 | +package org.springframework.cloud.kubernetes.client.config.reload_it; |
| 18 | + |
| 19 | +import java.time.Duration; |
| 20 | +import java.util.Base64; |
| 21 | +import java.util.List; |
| 22 | +import java.util.Map; |
| 23 | +import java.util.Set; |
| 24 | +import java.util.stream.Collector; |
| 25 | +import java.util.stream.Collectors; |
| 26 | + |
| 27 | +import com.github.tomakehurst.wiremock.WireMockServer; |
| 28 | +import com.github.tomakehurst.wiremock.client.WireMock; |
| 29 | +import io.kubernetes.client.openapi.ApiClient; |
| 30 | +import io.kubernetes.client.openapi.Configuration; |
| 31 | +import io.kubernetes.client.openapi.JSON; |
| 32 | +import io.kubernetes.client.openapi.apis.CoreV1Api; |
| 33 | +import io.kubernetes.client.openapi.models.V1ConfigMap; |
| 34 | +import io.kubernetes.client.openapi.models.V1ConfigMapBuilder; |
| 35 | +import io.kubernetes.client.openapi.models.V1ConfigMapList; |
| 36 | +import io.kubernetes.client.openapi.models.V1Secret; |
| 37 | +import io.kubernetes.client.openapi.models.V1SecretBuilder; |
| 38 | +import io.kubernetes.client.openapi.models.V1SecretList; |
| 39 | +import io.kubernetes.client.util.ClientBuilder; |
| 40 | + |
| 41 | +import org.awaitility.Awaitility; |
| 42 | +import org.junit.jupiter.api.AfterAll; |
| 43 | +import org.junit.jupiter.api.BeforeAll; |
| 44 | +import org.junit.jupiter.api.Test; |
| 45 | +import org.junit.jupiter.api.extension.ExtendWith; |
| 46 | +import org.springframework.boot.test.context.SpringBootTest; |
| 47 | +import org.springframework.boot.test.context.TestConfiguration; |
| 48 | +import org.springframework.boot.test.system.CapturedOutput; |
| 49 | +import org.springframework.boot.test.system.OutputCaptureExtension; |
| 50 | +import org.springframework.cloud.kubernetes.client.config.KubernetesClientConfigMapPropertySource; |
| 51 | +import org.springframework.cloud.kubernetes.client.config.KubernetesClientConfigMapPropertySourceLocator; |
| 52 | +import org.springframework.cloud.kubernetes.commons.KubernetesNamespaceProvider; |
| 53 | +import org.springframework.cloud.kubernetes.commons.config.ConfigMapConfigProperties; |
| 54 | +import org.springframework.cloud.kubernetes.commons.config.RetryProperties; |
| 55 | +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigReloadProperties; |
| 56 | +import org.springframework.cloud.kubernetes.commons.config.reload.ConfigurationUpdateStrategy; |
| 57 | +import org.springframework.cloud.kubernetes.commons.config.reload.PollingConfigMapChangeDetector; |
| 58 | +import org.springframework.context.annotation.Bean; |
| 59 | +import org.springframework.context.annotation.Primary; |
| 60 | +import org.springframework.core.env.AbstractEnvironment; |
| 61 | +import org.springframework.core.env.PropertySource; |
| 62 | +import org.springframework.mock.env.MockEnvironment; |
| 63 | +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; |
| 64 | + |
| 65 | +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; |
| 66 | +import static com.github.tomakehurst.wiremock.client.WireMock.get; |
| 67 | +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; |
| 68 | +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; |
| 69 | + |
| 70 | +/** |
| 71 | + * @author wind57 |
| 72 | + */ |
| 73 | +@SpringBootTest( |
| 74 | + properties = {"spring.main.allow-bean-definition-overriding=true", |
| 75 | + "logging.level.org.springframework.cloud.kubernetes.commons.config=debug" }, |
| 76 | + classes = { PollingReloadConfigMapTest.TestConfig.class }) |
| 77 | +@ExtendWith(OutputCaptureExtension.class) |
| 78 | +class PollingReloadSecretTest { |
| 79 | + |
| 80 | + private static WireMockServer wireMockServer; |
| 81 | + |
| 82 | + private static final boolean FAIL_FAST = false; |
| 83 | + |
| 84 | + private static final String SECRET_NAME = "mine"; |
| 85 | + |
| 86 | + private static final String NAMESPACE = "spring-k8s"; |
| 87 | + |
| 88 | + private static final boolean[] strategyCalled = new boolean[] { false }; |
| 89 | + |
| 90 | + private static CoreV1Api coreV1Api; |
| 91 | + |
| 92 | + @BeforeAll |
| 93 | + static void setup() { |
| 94 | + wireMockServer = new WireMockServer(options().dynamicPort()); |
| 95 | + |
| 96 | + wireMockServer.start(); |
| 97 | + WireMock.configureFor("localhost", wireMockServer.port()); |
| 98 | + |
| 99 | + ApiClient client = new ClientBuilder().setBasePath("http://localhost:" + wireMockServer.port()).build(); |
| 100 | + client.setDebugging(true); |
| 101 | + Configuration.setDefaultApiClient(client); |
| 102 | + coreV1Api = new CoreV1Api(); |
| 103 | + |
| 104 | + String path = "/api/v1/namespaces/spring-k8s/secrets"; |
| 105 | + V1Secret secretOne = secret(SECRET_NAME, Map.of()); |
| 106 | + V1SecretList listOne = new V1SecretList().addItemsItem(secretOne); |
| 107 | + |
| 108 | + // needed so that our environment is populated with 'something' |
| 109 | + // this call is done in the method that returns the AbstractEnvironment |
| 110 | + stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(listOne))) |
| 111 | + .inScenario("my-test").willSetStateTo("go-to-fail")); |
| 112 | + |
| 113 | + // first reload call fails |
| 114 | + stubFor(get(path).willReturn(aResponse().withStatus(500).withBody("Internal Server Error")) |
| 115 | + .inScenario("my-test").whenScenarioStateIs("go-to-fail").willSetStateTo("go-to-ok")); |
| 116 | + |
| 117 | + V1Secret secretTwo = secret(SECRET_NAME, Map.of("a", "b")); |
| 118 | + V1SecretList listTwo = new V1SecretList().addItemsItem(secretTwo); |
| 119 | + stubFor(get(path).willReturn(aResponse().withStatus(200).withBody(new JSON().serialize(listTwo))) |
| 120 | + .inScenario("my-test").whenScenarioStateIs("go-to-ok")); |
| 121 | + |
| 122 | + } |
| 123 | + |
| 124 | + @AfterAll |
| 125 | + static void after() { |
| 126 | + wireMockServer.stop(); |
| 127 | + } |
| 128 | + |
| 129 | + /** |
| 130 | + * <pre> |
| 131 | + * - we have a PropertySource in the environment |
| 132 | + * - first polling cycle tries to read the sources from k8s and fails |
| 133 | + * - second polling cycle reads sources from k8s and finds a change |
| 134 | + * </pre> |
| 135 | + */ |
| 136 | + @Test |
| 137 | + void test(CapturedOutput output) { |
| 138 | + // we fail while reading 'configMapOne' |
| 139 | + Awaitility.await().atMost(Duration.ofSeconds(10)).pollInterval(Duration.ofSeconds(1)).until(() -> { |
| 140 | + boolean one = output.getOut().contains("failure in reading named sources"); |
| 141 | + boolean two = output.getOut() |
| 142 | + .contains("there was an error while reading config maps/secrets, no reload will happen"); |
| 143 | + boolean three = output.getOut() |
| 144 | + .contains("reloadable condition was not satisfied, reload will not be triggered"); |
| 145 | + boolean updateStrategyNotCalled = !strategyCalled[0]; |
| 146 | + return one && two && three && updateStrategyNotCalled; |
| 147 | + }); |
| 148 | + |
| 149 | + // it passes while reading 'configMapTwo' |
| 150 | + Awaitility.await() |
| 151 | + .atMost(Duration.ofSeconds(10)) |
| 152 | + .pollInterval(Duration.ofSeconds(1)) |
| 153 | + .until(() -> strategyCalled[0]); |
| 154 | + } |
| 155 | + |
| 156 | + private static V1Secret secret(String name, Map<String, String> data) { |
| 157 | + |
| 158 | + Map<String, byte[]> encoded = data.entrySet().stream().collect( |
| 159 | + Collectors.toMap(e -> e.getKey(), e -> Base64.getEncoder().encode(e.getValue().getBytes())) |
| 160 | + ); |
| 161 | + |
| 162 | + return new V1SecretBuilder().withNewMetadata().withName(name).endMetadata() |
| 163 | + .withData(encoded).build(); |
| 164 | + } |
| 165 | + |
| 166 | + @TestConfiguration |
| 167 | + static class TestConfig { |
| 168 | + |
| 169 | + @Bean |
| 170 | + @Primary |
| 171 | + PollingConfigMapChangeDetector pollingConfigMapChangeDetector(AbstractEnvironment environment, |
| 172 | + ConfigReloadProperties configReloadProperties, ConfigurationUpdateStrategy configurationUpdateStrategy, |
| 173 | + KubernetesClientConfigMapPropertySourceLocator kubernetesClientConfigMapPropertySourceLocator) { |
| 174 | + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); |
| 175 | + scheduler.initialize(); |
| 176 | + return new PollingConfigMapChangeDetector(environment, configReloadProperties, configurationUpdateStrategy, |
| 177 | + KubernetesClientConfigMapPropertySource.class, kubernetesClientConfigMapPropertySourceLocator, scheduler); |
| 178 | + } |
| 179 | + |
| 180 | + @Bean |
| 181 | + @Primary |
| 182 | + AbstractEnvironment environment() { |
| 183 | + MockEnvironment mockEnvironment = new MockEnvironment(); |
| 184 | + mockEnvironment.setProperty("spring.cloud.kubernetes.client.namespace", NAMESPACE); |
| 185 | + |
| 186 | + // simulate that environment already has a Fabric8ConfigMapPropertySource, |
| 187 | + // otherwise we can't properly test reload functionality |
| 188 | + ConfigMapConfigProperties configMapConfigProperties = new ConfigMapConfigProperties(true, List.of(), |
| 189 | + List.of(), Map.of(), true, SECRET_NAME, NAMESPACE, false, true, true, RetryProperties.DEFAULT); |
| 190 | + KubernetesNamespaceProvider namespaceProvider = new KubernetesNamespaceProvider(mockEnvironment); |
| 191 | + |
| 192 | + PropertySource<?> propertySource = new KubernetesClientConfigMapPropertySourceLocator(coreV1Api, |
| 193 | + configMapConfigProperties, namespaceProvider) |
| 194 | + .locate(mockEnvironment); |
| 195 | + |
| 196 | + mockEnvironment.getPropertySources().addFirst(propertySource); |
| 197 | + return mockEnvironment; |
| 198 | + } |
| 199 | + |
| 200 | + @Bean |
| 201 | + @Primary |
| 202 | + ConfigReloadProperties configReloadProperties() { |
| 203 | + return new ConfigReloadProperties(true, true, false, ConfigReloadProperties.ReloadStrategy.REFRESH, |
| 204 | + ConfigReloadProperties.ReloadDetectionMode.POLLING, Duration.ofMillis(2000), Set.of("non-default"), |
| 205 | + false, Duration.ofSeconds(2)); |
| 206 | + } |
| 207 | + |
| 208 | + @Bean |
| 209 | + @Primary |
| 210 | + ConfigMapConfigProperties configMapConfigProperties() { |
| 211 | + return new ConfigMapConfigProperties(true, List.of(), List.of(), Map.of(), true, SECRET_NAME, NAMESPACE, |
| 212 | + false, true, FAIL_FAST, RetryProperties.DEFAULT); |
| 213 | + } |
| 214 | + |
| 215 | + @Bean |
| 216 | + @Primary |
| 217 | + KubernetesNamespaceProvider namespaceProvider(AbstractEnvironment environment) { |
| 218 | + return new KubernetesNamespaceProvider(environment); |
| 219 | + } |
| 220 | + |
| 221 | + @Bean |
| 222 | + @Primary |
| 223 | + ConfigurationUpdateStrategy configurationUpdateStrategy() { |
| 224 | + return new ConfigurationUpdateStrategy("to-console", () -> { |
| 225 | + strategyCalled[0] = true; |
| 226 | + }); |
| 227 | + } |
| 228 | + |
| 229 | + @Bean |
| 230 | + @Primary |
| 231 | + KubernetesClientConfigMapPropertySourceLocator kubernetesClientConfigMapPropertySourceLocator( |
| 232 | + ConfigMapConfigProperties configMapConfigProperties, KubernetesNamespaceProvider namespaceProvider) { |
| 233 | + return new KubernetesClientConfigMapPropertySourceLocator(coreV1Api, configMapConfigProperties, |
| 234 | + namespaceProvider); |
| 235 | + } |
| 236 | + |
| 237 | + } |
| 238 | + |
| 239 | +} |
0 commit comments