From 61e6bc78f111aeb40e6739ae7893311cd763688f Mon Sep 17 00:00:00 2001 From: evelynwei Date: Fri, 21 Aug 2026 11:49:27 +0800 Subject: [PATCH] feat: config effective Signed-off-by: evelynwei --- .gitignore | 1 + CHANGELOG.md | 1 + .../polaris/config/ConfigurationModifier.java | 9 + ...ConfigEffectiveValueAutoConfiguration.java | 70 ++++ .../SpringConfigEffectiveValueProvider.java | 288 ++++++++++++++++ .../config/PolarisConfigProperties.java | 42 +++ ...itional-spring-configuration-metadata.json | 7 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../config/ConfigurationModifierTest.java | 60 ++++ ...sConfigEffectiveValueRegistrationTest.java | 97 ++++++ ...pringConfigEffectiveValueProviderTest.java | 313 ++++++++++++++++++ 11 files changed, 889 insertions(+) create mode 100644 spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/PolarisConfigEffectiveValueAutoConfiguration.java create mode 100644 spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/SpringConfigEffectiveValueProvider.java create mode 100644 spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/PolarisConfigEffectiveValueRegistrationTest.java create mode 100644 spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/SpringConfigEffectiveValueProviderTest.java diff --git a/.gitignore b/.gitignore index 65975da2d..7f388619f 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ CLAUDE.md /backup backup */tls +/.cursor/ diff --git a/CHANGELOG.md b/CHANGELOG.md index db49346a9..8fc2d96db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,3 +14,4 @@ - [feat: support using seperated cb in wildcard-api level and add cb counters expire interval config](https://github.com/Tencent/spring-cloud-tencent/pull/1809) - [feat: adapt to polaris-java ReportClientRequestCustomizer plugin for config watch reporting](https://github.com/Tencent/spring-cloud-tencent/pull/1813) - [feat: support audit log](https://github.com/Tencent/spring-cloud-tencent/pull/1812) +- [feat: support config effective value ](https://github.com/Tencent/spring-cloud-tencent/pull/1815) diff --git a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/ConfigurationModifier.java b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/ConfigurationModifier.java index 72dd0bee9..8a7f5f234 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/ConfigurationModifier.java +++ b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/ConfigurationModifier.java @@ -32,6 +32,7 @@ import com.tencent.cloud.polaris.context.config.PolarisContextProperties; import com.tencent.polaris.api.config.consumer.OutlierDetectionConfig; import com.tencent.polaris.api.utils.CollectionUtils; import com.tencent.polaris.api.utils.StringUtils; +import com.tencent.polaris.configuration.client.internal.ConfigEffectiveQueryConfig; import com.tencent.polaris.configuration.client.internal.ConfigWatchReportRequestCustomizer; import com.tencent.polaris.configuration.client.internal.ConfigWatchReportRequestCustomizerConfig; import com.tencent.polaris.factory.config.ConfigurationImpl; @@ -76,6 +77,14 @@ public class ConfigurationModifier implements PolarisConfigurationConfigModifier ConfigWatchReportRequestCustomizer.NAME, ConfigWatchReportRequestCustomizerConfig.class); configWatchCustomizerConfig.setEnable(polarisConfigProperties.getReport().isEnabled()); customizerConfig.setPluginConfig(ConfigWatchReportRequestCustomizer.NAME, configWatchCustomizerConfig); + // enabled by default like the config watch report, and cascaded under report.enabled: + // disabling the whole report switch also disables the effective-time query channel + ConfigEffectiveQueryConfig configEffectiveConfig = customizerConfig.getPluginConfig( + ConfigEffectiveQueryConfig.NAME, ConfigEffectiveQueryConfig.class); + boolean effectiveEnabled = polarisConfigProperties.getReport().isEnabled() + && polarisConfigProperties.getReport().getEffective().isEnabled(); + configEffectiveConfig.setEnable(effectiveEnabled); + customizerConfig.setPluginConfig(ConfigEffectiveQueryConfig.NAME, configEffectiveConfig); configuration.getConsumer().getOutlierDetection().setWhen(OutlierDetectionConfig.When.never); configuration.getConsumer().getCircuitBreaker().setEnable(false); diff --git a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/PolarisConfigEffectiveValueAutoConfiguration.java b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/PolarisConfigEffectiveValueAutoConfiguration.java new file mode 100644 index 000000000..5e014c078 --- /dev/null +++ b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/PolarisConfigEffectiveValueAutoConfiguration.java @@ -0,0 +1,70 @@ +/* + * Tencent is pleased to support the open source community by making spring-cloud-tencent available. + * + * Copyright (C) 2021 Tencent. All rights reserved. + * + * Licensed under the BSD 3-Clause License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/BSD-3-Clause + * + * 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 com.tencent.cloud.polaris.config; + +import com.tencent.cloud.polaris.config.adapter.SpringConfigEffectiveValueProvider; +import com.tencent.polaris.configuration.api.core.ConfigEffectiveValueRegistration; +import com.tencent.polaris.configuration.api.core.ConfigFileService; + +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.ConfigurableEnvironment; + +/** + * Registers the Spring-Environment-based effective value provider into polaris-java + * for config effective-time realtime query. + *

+ * This auto-configuration is intentionally NOT registered as a BootstrapConfiguration: + * in legacy bootstrap mode the bootstrap context's Environment only carries bootstrap.yml + * sources, so a provider registered there would resolve stale or missing effective values. + * Being main-context-only guarantees the provider always captures the Environment the + * application actually reads. In legacy bootstrap mode the {@link ConfigFileService} bean + * is inherited from the bootstrap (parent) context. + * + * @author evelynwei + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnPolarisConfigEnabled +@AutoConfigureAfter(PolarisConfigBootstrapAutoConfiguration.class) +public class PolarisConfigEffectiveValueAutoConfiguration { + + /** + * Enabled by default (aligned with the config watch report switch); set + * spring.cloud.polaris.config.report.effective.enabled=false to opt out. The returned + * registration is closed on context shutdown so the SDK never holds a destroyed + * Environment. + *

+ * {@code @AutoConfigureAfter} on this class guarantees the {@code configFileService} + * bean definition of {@link PolarisConfigBootstrapAutoConfiguration} is already + * processed, so {@code @ConditionalOnBean} sees it in ConfigData mode; in legacy + * bootstrap mode the bean is visible in the parent context. + */ + @Bean(destroyMethod = "close") + @ConditionalOnBean(ConfigFileService.class) + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "spring.cloud.polaris.config.report.effective", name = "enabled", + havingValue = "true", matchIfMissing = true) + public ConfigEffectiveValueRegistration polarisConfigEffectiveValueRegistration( + ConfigurableEnvironment environment, ConfigFileService configFileService) { + return configFileService.registerEffectiveValueProvider(new SpringConfigEffectiveValueProvider(environment)); + } +} diff --git a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/SpringConfigEffectiveValueProvider.java b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/SpringConfigEffectiveValueProvider.java new file mode 100644 index 000000000..092692ede --- /dev/null +++ b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/SpringConfigEffectiveValueProvider.java @@ -0,0 +1,288 @@ +/* + * Tencent is pleased to support the open source community by making spring-cloud-tencent available. + * + * Copyright (C) 2021 Tencent. All rights reserved. + * + * Licensed under the BSD 3-Clause License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/BSD-3-Clause + * + * 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 com.tencent.cloud.polaris.config.adapter; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import com.tencent.polaris.configuration.api.core.ConfigEffectiveValueProvider; +import com.tencent.polaris.configuration.api.core.ConfigFileMetadata; +import com.tencent.polaris.configuration.api.core.ConfigKVFile; +import com.tencent.polaris.configuration.api.core.ConfigKeyConflict; +import com.tencent.polaris.configuration.api.core.EffectiveValue; +import com.tencent.polaris.configuration.client.internal.CompositeConfigFile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.cloud.bootstrap.config.BootstrapPropertySource; +import org.springframework.core.env.CompositePropertySource; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.PropertySource; + +/** + * Resolves key list, file value, effective value and conflict context from Spring + * {@link ConfigurableEnvironment}, for polaris-java config effective-time query. + *

+ * Config values must never be written to any log (including DEBUG): exception messages + * and stack traces may embed raw values (e.g. Spring's unresolvable-placeholder error + * quotes the whole value), so logs only carry key names, file coordinates and exception + * class names. Per the interface contract, methods must not throw: single-key failures + * degrade that key only. + * + * @author evelynwei + */ +public class SpringConfigEffectiveValueProvider implements ConfigEffectiveValueProvider { + + private static final Logger LOG = LoggerFactory.getLogger(SpringConfigEffectiveValueProvider.class); + + private static final String SOURCE_PREFIX = "polaris:"; + + /** + * Name of Spring Boot's configuration-properties facade source + * ({@code ConfigurationPropertySourcesPropertySource}). Attached at the head of the + * Environment, its containsProperty delegates to every underlying source, so it + * matches any key and would shadow the real source. + */ + private static final String CONFIGURATION_PROPERTIES_SOURCE_NAME = "configurationProperties"; + + private final ConfigurableEnvironment environment; + + public SpringConfigEffectiveValueProvider(ConfigurableEnvironment environment) { + this.environment = environment; + } + + @Override + public List getKeys(ConfigFileMetadata configFile) { + try { + ConfigKVFile file = findConfigKVFile(configFile); + if (file == null) { + return Collections.emptyList(); + } + List keys = new ArrayList<>(file.getPropertyNames()); + // Sort for stable output, friendly to tests and console display. + Collections.sort(keys); + return keys; + } + catch (Throwable t) { + LOG.warn("[SCT Config] Get keys failed, file = {}, error = {}", coordinateOf(configFile), + t.getClass().getSimpleName()); + return Collections.emptyList(); + } + } + + @Override + public EffectiveValue resolve(String key, ConfigFileMetadata configFile) { + String fileValue; + try { + ConfigKVFile file = findConfigKVFile(configFile); + // Raw value in the file (may contain unresolved placeholders). + fileValue = file == null ? null : file.getProperty(key, null); + } + catch (Throwable t) { + // Contract: return null instead of throwing; SDK degrades this key only. + LOG.warn("[SCT Config] Resolve file value failed, key = {}, file = {}, error = {}", key, + coordinateOf(configFile), t.getClass().getSimpleName()); + return null; + } + String effectiveValue = null; + String propertySource = null; + try { + // Effective value: Environment has already converged by precedence and + // resolves ${} placeholders, i.e. the value the application actually reads. + effectiveValue = environment.getProperty(key); + propertySource = resolvePropertySourceName(key); + } + catch (Throwable t) { + // Only degrade the effective-value dimension; the file value already in hand is returned. + LOG.warn("[SCT Config] Resolve effective value failed, key = {}, error = {}", key, + t.getClass().getSimpleName()); + } + return new EffectiveValue(fileValue, effectiveValue, propertySource); + } + + @Override + public List resolveConflicts(String key, ConfigFileMetadata excludeFile) { + List conflicts = new ArrayList<>(); + // Static call: PolarisPropertySourceManager is not a bean and cannot be injected. + // Used here only to enumerate the watched-file set, never for precedence. + for (PolarisPropertySource source : PolarisPropertySourceManager.getAllPropertySources()) { + for (ConfigKVFile file : expandSubs(source.getConfigKVFile())) { + try { + collectIfConflict(conflicts, key, file, excludeFile); + } + catch (Throwable t) { + // Per-file fallback: one broken file must not drop conflicts already collected. + LOG.warn("[SCT Config] Resolve conflicts failed, key = {}, error = {}", key, + t.getClass().getSimpleName()); + } + } + } + return conflicts; + } + + /** + * Walks the ordered PropertySource chain of the Environment and returns the source + * identity of the first one containing the key. Iteration order of + * MutablePropertySources is exactly Spring's precedence order. + */ + private String resolvePropertySourceName(String key) { + for (PropertySource source : environment.getPropertySources()) { + String name = matchSource(source, key); + if (name != null) { + return name; + } + } + return null; + } + + /** + * Matches the key inside a single PropertySource, unwrapping wrappers first: + * in bootstrap mode polaris sources are wrapped into a CompositePropertySource + * ("polaris-config") and then into BootstrapPropertySource, so recursion is needed + * to reach the real PolarisPropertySource. Polaris sources are translated to a + * normalized coordinate {@code polaris:namespace/group/fileName}. + */ + private String matchSource(PropertySource source, String key) { + if (CONFIGURATION_PROPERTIES_SOURCE_NAME.equals(source.getName())) { + return null; + } + if (source instanceof CompositePropertySource) { + for (PropertySource sub : ((CompositePropertySource) source).getPropertySources()) { + String name = matchSource(sub, key); + if (name != null) { + return name; + } + } + return null; + } + if (source instanceof BootstrapPropertySource) { + return matchSource(((BootstrapPropertySource) source).getDelegate(), key); + } + if (!source.containsProperty(key)) { + return null; + } + if (source instanceof PolarisPropertySource) { + PolarisPropertySource polarisSource = (PolarisPropertySource) source; + ConfigKVFile file = polarisSource.getConfigKVFile(); + if (file instanceof CompositeConfigFile) { + // Group-dimension load: merge semantics is first-file-wins, so the first sub + // file containing the key is the effective one. + for (ConfigKVFile sub : ((CompositeConfigFile) file).getConfigKVFiles()) { + if (sub.getProperty(key, null) != null) { + return formatCoordinate(sub); + } + } + // Fallback: the composite's sub list is frozen at startup and does not cover + // files added to the group at runtime; look them up by group coordinate. + ConfigKVFile added = findInGroup(polarisSource.getNamespace(), polarisSource.getGroup(), key); + if (added != null) { + return formatCoordinate(added); + } + } + else { + return formatCoordinate(file); + } + } + return source.getName(); + } + + /** + * Finds the watched ConfigKVFile by coordinate. Sources loaded by file dimension match + * directly; sources loaded by group dimension carry an empty fileName and their + * CompositeConfigFile must be expanded to match sub files. + */ + private ConfigKVFile findConfigKVFile(ConfigFileMetadata metadata) { + for (PolarisPropertySource source : PolarisPropertySourceManager.getAllPropertySources()) { + for (ConfigKVFile file : expandSubs(source.getConfigKVFile())) { + if (sameFile(file, metadata)) { + return file; + } + } + } + return null; + } + + /** + * Finds a watched file in the given namespace/group containing the key. Used as the + * fallback for group sources whose frozen composite misses runtime-added files. + */ + private ConfigKVFile findInGroup(String namespace, String group, String key) { + for (PolarisPropertySource source : PolarisPropertySourceManager.getAllPropertySources()) { + for (ConfigKVFile file : expandSubs(source.getConfigKVFile())) { + if (Objects.equals(file.getNamespace(), namespace) && Objects.equals(file.getFileGroup(), group) + && file.getProperty(key, null) != null) { + return file; + } + } + } + return null; + } + + /** + * Expands a CompositeConfigFile to its sub files; a plain file expands to itself. + */ + private List expandSubs(ConfigKVFile file) { + if (file instanceof CompositeConfigFile) { + List subs = ((CompositeConfigFile) file).getConfigKVFiles(); + return subs == null ? Collections.emptyList() : subs; + } + return Collections.singletonList(file); + } + + private void collectIfConflict(List conflicts, String key, ConfigKVFile candidate, + ConfigFileMetadata excludeFile) { + if (sameFile(candidate, excludeFile)) { + return; + } + String value = candidate.getProperty(key, null); + if (value == null) { + return; + } + // The same file may be watched at both file dimension and group dimension; dedupe by coordinate. + for (ConfigKeyConflict existing : conflicts) { + if (Objects.equals(existing.getNamespace(), candidate.getNamespace()) + && Objects.equals(existing.getGroup(), candidate.getFileGroup()) + && Objects.equals(existing.getFileName(), candidate.getFileName())) { + return; + } + } + // Coordinate + value only; never the full content of the conflict file. + conflicts.add(new ConfigKeyConflict(candidate.getNamespace(), candidate.getFileGroup(), + candidate.getFileName(), value)); + } + + private boolean sameFile(ConfigKVFile candidate, ConfigFileMetadata metadata) { + return metadata != null + && Objects.equals(candidate.getNamespace(), metadata.getNamespace()) + && Objects.equals(candidate.getFileGroup(), metadata.getFileGroup()) + && Objects.equals(candidate.getFileName(), metadata.getFileName()); + } + + private String formatCoordinate(ConfigKVFile file) { + return SOURCE_PREFIX + file.getNamespace() + "/" + file.getFileGroup() + "/" + file.getFileName(); + } + + private String coordinateOf(ConfigFileMetadata metadata) { + if (metadata == null) { + return "null"; + } + return metadata.getNamespace() + "/" + metadata.getFileGroup() + "/" + metadata.getFileName(); + } +} diff --git a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/config/PolarisConfigProperties.java b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/config/PolarisConfigProperties.java index 261f05643..3d2d50221 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/config/PolarisConfigProperties.java +++ b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/config/PolarisConfigProperties.java @@ -275,6 +275,11 @@ public class PolarisConfigProperties { */ private boolean enabled = true; + /** + * Config effective-time realtime query settings. + */ + private Effective effective = new Effective(); + public boolean isEnabled() { return enabled; } @@ -283,11 +288,48 @@ public class PolarisConfigProperties { this.enabled = enabled; } + public Effective getEffective() { + return effective; + } + + public void setEffective(Effective effective) { + this.effective = effective; + } + @Override public String toString() { return "Report{" + "enabled=" + enabled + + ", effective=" + effective + '}'; } + + /** + * Config effective-time realtime query settings. + */ + public static class Effective { + + /** + * Whether to enable config effective-time realtime query. + * Default true, aligned with the config watch metadata report switch. + * Effective only when {@link Report#enabled} is also true (cascaded). + */ + private boolean enabled = true; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + @Override + public String toString() { + return "Effective{" + + "enabled=" + enabled + + '}'; + } + } } } diff --git a/spring-cloud-starter-tencent-polaris-config/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-cloud-starter-tencent-polaris-config/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 8c94b1e71..874fd0497 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-cloud-starter-tencent-polaris-config/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -92,6 +92,13 @@ "description": "Whether to report config watch metadata through the Polaris client reporter.", "sourceType": "com.tencent.cloud.polaris.config.config.PolarisConfigProperties" }, + { + "name": "spring.cloud.polaris.config.report.effective.enabled", + "type": "java.lang.Boolean", + "defaultValue": true, + "description": "Whether to enable config effective-time realtime query. Default true and cascaded under spring.cloud.polaris.config.report.enabled (disabling the whole report switch also disables this query channel).", + "sourceType": "com.tencent.cloud.polaris.config.config.PolarisConfigProperties" + }, { "name": "spring.cloud.polaris.config.crypto.enabled", "type": "java.lang.Boolean", diff --git a/spring-cloud-starter-tencent-polaris-config/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-cloud-starter-tencent-polaris-config/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index bad1ece40..e42410bc5 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-cloud-starter-tencent-polaris-config/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,4 +1,5 @@ com.tencent.cloud.polaris.config.PolarisConfigAutoConfiguration com.tencent.cloud.polaris.config.endpoint.PolarisConfigEndpointAutoConfiguration com.tencent.cloud.polaris.config.PolarisConfigBootstrapAutoConfiguration +com.tencent.cloud.polaris.config.PolarisConfigEffectiveValueAutoConfiguration com.tencent.cloud.polaris.config.tsf.PolarisAdaptorTsfConfigAutoConfiguration diff --git a/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/ConfigurationModifierTest.java b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/ConfigurationModifierTest.java index 4a46887fa..84558f28a 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/ConfigurationModifierTest.java +++ b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/ConfigurationModifierTest.java @@ -32,6 +32,7 @@ import com.tencent.cloud.polaris.config.config.PolarisConfigProperties; import com.tencent.cloud.polaris.config.config.PolarisCryptoConfigProperties; import com.tencent.cloud.polaris.context.config.PolarisContextProperties; import com.tencent.polaris.api.config.consumer.OutlierDetectionConfig; +import com.tencent.polaris.configuration.client.internal.ConfigEffectiveQueryConfig; import com.tencent.polaris.configuration.client.internal.ConfigWatchReportRequestCustomizer; import com.tencent.polaris.configuration.client.internal.ConfigWatchReportRequestCustomizerConfig; import com.tencent.polaris.factory.config.ConfigurationImpl; @@ -81,12 +82,18 @@ class ConfigurationModifierTest { @Mock private PolarisConfigProperties.Report report; + @Mock + private PolarisConfigProperties.Report.Effective effective; + @Mock private PolarisContextProperties polarisContextProperties; @Mock private ConfigWatchReportRequestCustomizerConfig configWatchCustomizer; + @Mock + private ConfigEffectiveQueryConfig configEffectiveCustomizer; + private ConfigurationModifier configurationModifier; @BeforeEach @@ -95,6 +102,8 @@ class ConfigurationModifierTest { polarisConfigProperties, polarisCryptoConfigProperties, polarisContextProperties); Mockito.lenient().when(polarisConfigProperties.getReport()).thenReturn(report); Mockito.lenient().when(report.isEnabled()).thenReturn(true); + Mockito.lenient().when(report.getEffective()).thenReturn(effective); + Mockito.lenient().when(effective.isEnabled()).thenReturn(true); } /** @@ -113,6 +122,8 @@ class ConfigurationModifierTest { when(globalConfig.getReportClientRequestCustomizer()).thenReturn(requestCustomizer); when(requestCustomizer.getPluginConfig(ConfigWatchReportRequestCustomizer.NAME, ConfigWatchReportRequestCustomizerConfig.class)).thenReturn(configWatchCustomizer); + when(requestCustomizer.getPluginConfig(ConfigEffectiveQueryConfig.NAME, + ConfigEffectiveQueryConfig.class)).thenReturn(configEffectiveCustomizer); Mockito.lenient().when(globalConfig.getServerConnector()).thenReturn(serverConnector); Mockito.lenient().when(globalConfig.getAPI()).thenReturn(apiConfig); when(configuration.getGlobal()).thenReturn(globalConfig); @@ -220,6 +231,55 @@ class ConfigurationModifierTest { verify(configuration.getConfigFile(), never()).getServerConnector(); } + @DisplayName("modify should disable config effective query when configured") + @Test + void testModify_ConfigEffectiveQueryDisabled() { + ConfigurationImpl configuration = buildMockConfiguration(); + when(effective.isEnabled()).thenReturn(false); + when(polarisContextProperties.getEnabled()).thenReturn(false); + + configurationModifier.modify(configuration); + + // an explicit false always overrides the SDK-side default + verify(configEffectiveCustomizer).setEnable(false); + verify(configuration.getGlobal().getReportClientRequestCustomizer()) + .setPluginConfig(ConfigEffectiveQueryConfig.NAME, configEffectiveCustomizer); + verify(configuration.getConfigFile(), never()).getServerConnector(); + } + + @DisplayName("modify should enable config effective query when configured") + @Test + void testModify_ConfigEffectiveQueryEnabled() { + ConfigurationImpl configuration = buildMockConfiguration(); + when(effective.isEnabled()).thenReturn(true); + when(polarisContextProperties.getEnabled()).thenReturn(false); + + configurationModifier.modify(configuration); + + verify(configEffectiveCustomizer).setEnable(true); + verify(configuration.getGlobal().getReportClientRequestCustomizer()) + .setPluginConfig(ConfigEffectiveQueryConfig.NAME, configEffectiveCustomizer); + verify(configuration.getConfigFile(), never()).getServerConnector(); + } + + @DisplayName("modify should cascade: report disabled also disables config effective query") + @Test + void testModify_ConfigEffectiveQueryCascadesWithReportDisabled() { + ConfigurationImpl configuration = buildMockConfiguration(); + // report.enabled=false 级联关闭 effective query,即使 effective.enabled 显式为 true + // (级联短路后 effective.isEnabled() 不再被读取,故用 lenient) + when(report.isEnabled()).thenReturn(false); + Mockito.lenient().when(effective.isEnabled()).thenReturn(true); + when(polarisContextProperties.getEnabled()).thenReturn(false); + + configurationModifier.modify(configuration); + + verify(configEffectiveCustomizer).setEnable(false); + verify(configuration.getGlobal().getReportClientRequestCustomizer()) + .setPluginConfig(ConfigEffectiveQueryConfig.NAME, configEffectiveCustomizer); + verify(configuration.getConfigFile(), never()).getServerConnector(); + } + /** * Test modify with local file data source. * Scenario: dataSource is "localFile", both polaris and config are enabled. diff --git a/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/PolarisConfigEffectiveValueRegistrationTest.java b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/PolarisConfigEffectiveValueRegistrationTest.java new file mode 100644 index 000000000..a20ee68b5 --- /dev/null +++ b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/PolarisConfigEffectiveValueRegistrationTest.java @@ -0,0 +1,97 @@ +/* + * Tencent is pleased to support the open source community by making spring-cloud-tencent available. + * + * Copyright (C) 2021 Tencent. All rights reserved. + * + * Licensed under the BSD 3-Clause License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/BSD-3-Clause + * + * 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 com.tencent.cloud.polaris.config; + +import com.tencent.cloud.polaris.config.adapter.SpringConfigEffectiveValueProvider; +import com.tencent.polaris.configuration.api.core.ConfigEffectiveValueProvider; +import com.tencent.polaris.configuration.api.core.ConfigEffectiveValueRegistration; +import com.tencent.polaris.configuration.api.core.ConfigFileService; +import com.tencent.polaris.configuration.api.core.EffectiveValue; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Test for {@link PolarisConfigEffectiveValueAutoConfiguration}. The registration bean must + * only be created in the main application context: in legacy bootstrap mode the bootstrap + * context's Environment carries only bootstrap.yml sources, so a provider registered there + * would report stale effective values. + * + * @author evelynwei + */ +// The mocked AutoCloseable registrations are never closed; the real bean is closed via destroyMethod. +@SuppressWarnings("try") +class PolarisConfigEffectiveValueRegistrationTest { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(PolarisConfigEffectiveValueAutoConfiguration.class)); + + @Test + void testRegistrationBeanCreatedWhenConfigFileServicePresent() { + ConfigFileService configFileService = mock(ConfigFileService.class); + ConfigEffectiveValueRegistration registration = mock(ConfigEffectiveValueRegistration.class); + when(configFileService.registerEffectiveValueProvider(any(ConfigEffectiveValueProvider.class))) + .thenReturn(registration); + + runner.withBean(ConfigFileService.class, () -> configFileService) + .run(context -> { + assertThat(context).hasSingleBean(ConfigEffectiveValueRegistration.class); + verify(configFileService).registerEffectiveValueProvider( + any(SpringConfigEffectiveValueProvider.class)); + }); + } + + @Test + void testRegistrationBeanSkippedWhenConfigFileServiceAbsent() { + // local data source etc.: no ConfigFileService bean, provider must not be registered + runner.run(context -> assertThat(context).doesNotHaveBean(ConfigEffectiveValueRegistration.class)); + } + + @Test + void testRegistrationBeanSkippedWhenExplicitlyDisabled() { + ConfigFileService configFileService = mock(ConfigFileService.class); + runner.withBean(ConfigFileService.class, () -> configFileService) + .withPropertyValues("spring.cloud.polaris.config.report.effective.enabled=false") + .run(context -> assertThat(context).doesNotHaveBean(ConfigEffectiveValueRegistration.class)); + } + + @Test + void testProviderResolvesAgainstRegisteringContextEnvironment() { + ConfigFileService configFileService = mock(ConfigFileService.class); + ConfigEffectiveValueRegistration registration = mock(ConfigEffectiveValueRegistration.class); + ArgumentCaptor providerCaptor = + ArgumentCaptor.forClass(ConfigEffectiveValueProvider.class); + when(configFileService.registerEffectiveValueProvider(providerCaptor.capture())) + .thenReturn(registration); + + runner.withBean(ConfigFileService.class, () -> configFileService) + .withPropertyValues("sct.test.key=from-main-environment") + .run(context -> { + EffectiveValue value = providerCaptor.getValue().resolve("sct.test.key", null); + assertThat(value.getEffectiveValue()).isEqualTo("from-main-environment"); + }); + } +} diff --git a/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/SpringConfigEffectiveValueProviderTest.java b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/SpringConfigEffectiveValueProviderTest.java new file mode 100644 index 000000000..18478f8ed --- /dev/null +++ b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/SpringConfigEffectiveValueProviderTest.java @@ -0,0 +1,313 @@ +/* + * Tencent is pleased to support the open source community by making spring-cloud-tencent available. + * + * Copyright (C) 2021 Tencent. All rights reserved. + * + * Licensed under the BSD 3-Clause License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/BSD-3-Clause + * + * 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 com.tencent.cloud.polaris.config.adapter; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.tencent.polaris.configuration.api.core.ConfigFileMetadata; +import com.tencent.polaris.configuration.api.core.ConfigKVFile; +import com.tencent.polaris.configuration.api.core.ConfigKeyConflict; +import com.tencent.polaris.configuration.api.core.EffectiveValue; +import com.tencent.polaris.configuration.client.internal.CompositeConfigFile; +import com.tencent.polaris.configuration.client.internal.DefaultConfigFileMetadata; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.cloud.bootstrap.config.BootstrapPropertySource; +import org.springframework.core.env.CompositePropertySource; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.StandardEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Test for {@link SpringConfigEffectiveValueProvider}. + * + * @author evelynwei + */ +@ExtendWith(MockitoExtension.class) +class SpringConfigEffectiveValueProviderTest { + + private static final String NAMESPACE = "default"; + + private static final String GROUP = "order-service"; + + private static final String FILE_NAME = "application.yaml"; + + private StandardEnvironment environment; + + private SpringConfigEffectiveValueProvider provider; + + @BeforeEach + void setUp() { + PolarisPropertySourceManager.clearPropertySources(); + environment = new StandardEnvironment(); + provider = new SpringConfigEffectiveValueProvider(environment); + } + + @AfterEach + void tearDown() { + PolarisPropertySourceManager.clearPropertySources(); + } + + /** + * Registers a file-dimension PolarisPropertySource into both the Environment chain + * (at the tail, below system properties like the real runtime) and the static manager. + */ + private PolarisPropertySource addFileSource(String namespace, String group, String fileName, + Map props) { + ConfigKVFile kvFile = mockConfigKVFile(namespace, group, fileName, props); + PolarisPropertySource source = new PolarisPropertySource(namespace, group, fileName, kvFile, + new HashMap(props)); + PolarisPropertySourceManager.addPropertySource(source); + environment.getPropertySources().addLast(source); + return source; + } + + private ConfigKVFile mockConfigKVFile(String namespace, String group, String fileName, + Map props) { + ConfigKVFile kvFile = mock(ConfigKVFile.class); + lenient().when(kvFile.getNamespace()).thenReturn(namespace); + lenient().when(kvFile.getFileGroup()).thenReturn(group); + lenient().when(kvFile.getFileName()).thenReturn(fileName); + lenient().when(kvFile.getPropertyNames()).thenReturn(props.keySet()); + lenient().when(kvFile.getProperty(anyString(), any())).thenAnswer(invocation -> { + String key = invocation.getArgument(0); + return props.getOrDefault(key, invocation.getArgument(1)); + }); + return kvFile; + } + + private ConfigFileMetadata metadata(String namespace, String group, String fileName) { + return new DefaultConfigFileMetadata(namespace, group, fileName); + } + + @Test + void testCommandLineArgsOverridesPolarisValue() { + addFileSource(NAMESPACE, GROUP, FILE_NAME, Collections.singletonMap("server.port", "8080")); + environment.getPropertySources().addFirst(new MapPropertySource("commandLineArgs", + Collections.singletonMap("server.port", "9090"))); + + EffectiveValue value = provider.resolve("server.port", metadata(NAMESPACE, GROUP, FILE_NAME)); + + assertThat(value).isNotNull(); + assertThat(value.getFileValue()).isEqualTo("8080"); + assertThat(value.getEffectiveValue()).isEqualTo("9090"); + assertThat(value.getPropertySource()).isEqualTo("commandLineArgs"); + } + + @Test + void testEffectiveValueResolvesPlaceholder() { + Map props = new LinkedHashMap<>(); + props.put("server.port", "${http.port:8080}"); + props.put("http.port", "8081"); + addFileSource(NAMESPACE, GROUP, FILE_NAME, props); + + EffectiveValue value = provider.resolve("server.port", metadata(NAMESPACE, GROUP, FILE_NAME)); + + assertThat(value).isNotNull(); + // file value keeps the raw placeholder, effective value is what the application reads + assertThat(value.getFileValue()).isEqualTo("${http.port:8080}"); + assertThat(value.getEffectiveValue()).isEqualTo("8081"); + // still sourced from the polaris file itself: normalized coordinate + assertThat(value.getPropertySource()).isEqualTo("polaris:default/order-service/application.yaml"); + } + + @Test + void testGetKeysSortedAndComplete() { + Map props = new LinkedHashMap<>(); + props.put("b.key", "2"); + props.put("a.key", "1"); + props.put("c.key", "3"); + addFileSource(NAMESPACE, GROUP, FILE_NAME, props); + + List keys = provider.getKeys(metadata(NAMESPACE, GROUP, FILE_NAME)); + + assertThat(keys).containsExactly("a.key", "b.key", "c.key"); + // unknown coordinate: empty list, and SDK will omit the whole properties field + assertThat(provider.getKeys(metadata(NAMESPACE, GROUP, "unknown.yaml"))).isEmpty(); + } + + @Test + void testConflictsAcrossMultipleWatchedFiles() { + addFileSource(NAMESPACE, GROUP, FILE_NAME, Collections.singletonMap("k", "1")); + addFileSource(NAMESPACE, "common", "common.yaml", Collections.singletonMap("k", "2")); + + List conflicts = provider.resolveConflicts("k", metadata(NAMESPACE, GROUP, FILE_NAME)); + + assertThat(conflicts).hasSize(1); + ConfigKeyConflict conflict = conflicts.get(0); + assertThat(conflict.getNamespace()).isEqualTo(NAMESPACE); + assertThat(conflict.getGroup()).isEqualTo("common"); + assertThat(conflict.getFileName()).isEqualTo("common.yaml"); + assertThat(conflict.getValue()).isEqualTo("2"); + + // querying from the other side excludes itself and reports application.yaml + List reverse = provider.resolveConflicts("k", metadata(NAMESPACE, "common", "common.yaml")); + assertThat(reverse).hasSize(1); + assertThat(reverse.get(0).getFileName()).isEqualTo(FILE_NAME); + assertThat(reverse.get(0).getValue()).isEqualTo("1"); + } + + @Test + void testCompositeConfigFileExpanded() { + // group-dimension loading: sub files merged into a CompositeConfigFile, first file wins + ConfigKVFile subA = mockConfigKVFile(NAMESPACE, "mygroup", "a.yaml", Collections.singletonMap("k", "1")); + ConfigKVFile subB = mockConfigKVFile(NAMESPACE, "mygroup", "b.yaml", Collections.singletonMap("k", "2")); + CompositeConfigFile composite = new CompositeConfigFile(Arrays.asList(subA, subB)); + Map merged = new HashMap<>(); + merged.put("k", "1"); + PolarisPropertySource groupSource = new PolarisPropertySource(NAMESPACE, "mygroup", "", composite, merged); + PolarisPropertySourceManager.addPropertySource(groupSource); + environment.getPropertySources().addLast(groupSource); + + // query the losing sub file b.yaml + EffectiveValue value = provider.resolve("k", metadata(NAMESPACE, "mygroup", "b.yaml")); + + assertThat(value).isNotNull(); + // file value from sub file b, effective value from the merged group map (a wins) + assertThat(value.getFileValue()).isEqualTo("2"); + assertThat(value.getEffectiveValue()).isEqualTo("1"); + // property source points to the winning sub file, not the opaque group source name + assertThat(value.getPropertySource()).isEqualTo("polaris:default/mygroup/a.yaml"); + + // conflicts are sub-file grained: b is excluded, a is reported + List conflicts = provider.resolveConflicts("k", metadata(NAMESPACE, "mygroup", "b.yaml")); + assertThat(conflicts).hasSize(1); + assertThat(conflicts.get(0).getFileName()).isEqualTo("a.yaml"); + assertThat(conflicts.get(0).getValue()).isEqualTo("1"); + + // getKeys finds the sub file inside the composite + assertThat(provider.getKeys(metadata(NAMESPACE, "mygroup", "a.yaml"))).containsExactly("k"); + } + + @Test + void testResolveKeepsFileValueWhenEffectiveResolutionFails() { + // environment.getProperty 抛异常(如不可解析占位符):生效值维度降级为 null, + // 但已到手的文件原始值照常返回 + addFileSource(NAMESPACE, GROUP, FILE_NAME, Collections.singletonMap("k", "file-v")); + ConfigurableEnvironment broken = mock(ConfigurableEnvironment.class); + when(broken.getProperty(anyString())) + .thenThrow(new IllegalArgumentException("Could not resolve placeholder")); + SpringConfigEffectiveValueProvider failingProvider = new SpringConfigEffectiveValueProvider(broken); + + EffectiveValue value = failingProvider.resolve("k", metadata(NAMESPACE, GROUP, FILE_NAME)); + + assertThat(value).isNotNull(); + assertThat(value.getFileValue()).isEqualTo("file-v"); + assertThat(value.getEffectiveValue()).isNull(); + assertThat(value.getPropertySource()).isNull(); + } + + @Test + void testConflictsSkipsBrokenFile() { + // 一个坏文件(getProperty 抛异常)不应丢弃已从健康文件收集的冲突 + addFileSource(NAMESPACE, "common", "common.yaml", Collections.singletonMap("k", "2")); + ConfigKVFile broken = mock(ConfigKVFile.class); + lenient().when(broken.getNamespace()).thenReturn(NAMESPACE); + lenient().when(broken.getFileGroup()).thenReturn("broken-group"); + lenient().when(broken.getFileName()).thenReturn("broken.yaml"); + when(broken.getProperty(anyString(), any())).thenThrow(new RuntimeException("boom")); + PolarisPropertySource brokenSource = new PolarisPropertySource(NAMESPACE, "broken-group", "broken.yaml", + broken, new HashMap()); + PolarisPropertySourceManager.addPropertySource(brokenSource); + + List conflicts = provider.resolveConflicts("k", metadata(NAMESPACE, GROUP, FILE_NAME)); + + assertThat(conflicts).hasSize(1); + assertThat(conflicts.get(0).getFileName()).isEqualTo("common.yaml"); + assertThat(conflicts.get(0).getValue()).isEqualTo("2"); + } + + @Test + void testConflictsDedupWhenWatchedAtBothDimensions() { + // 同一文件同时以文件维度与 group 维度监听注册:冲突按坐标去重 + Map props = Collections.singletonMap("k", "2"); + addFileSource(NAMESPACE, "common", "common.yaml", props); + ConfigKVFile sub = mockConfigKVFile(NAMESPACE, "common", "common.yaml", props); + CompositeConfigFile composite = new CompositeConfigFile(Collections.singletonList(sub)); + PolarisPropertySource groupSource = new PolarisPropertySource(NAMESPACE, "common", "", composite, + new HashMap(props)); + PolarisPropertySourceManager.addPropertySource(groupSource); + + List conflicts = provider.resolveConflicts("k", metadata(NAMESPACE, GROUP, FILE_NAME)); + + assertThat(conflicts).hasSize(1); + assertThat(conflicts.get(0).getFileName()).isEqualTo("common.yaml"); + assertThat(conflicts.get(0).getValue()).isEqualTo("2"); + } + + @Test + void testBootstrapWrappedSourcesUnwrapped() { + // bootstrap 模式:PolarisPropertySource 被 CompositePropertySource("polaris-config") 聚合, + // 外层再被 BootstrapPropertySource 包装——来源解析需递归解包出坐标 + PolarisPropertySource polarisSource = addFileSource(NAMESPACE, GROUP, FILE_NAME, + Collections.singletonMap("k", "1")); + environment.getPropertySources().remove(polarisSource.getName()); + CompositePropertySource composite = new CompositePropertySource("polaris-config"); + composite.addPropertySource(polarisSource); + environment.getPropertySources().addLast(new BootstrapPropertySource<>(composite)); + + EffectiveValue value = provider.resolve("k", metadata(NAMESPACE, GROUP, FILE_NAME)); + + assertThat(value).isNotNull(); + assertThat(value.getEffectiveValue()).isEqualTo("1"); + assertThat(value.getPropertySource()).isEqualTo("polaris:default/order-service/application.yaml"); + } + + @Test + void testConfigurationPropertiesFacadeSourceSkipped() { + // Spring Boot 的 configurationProperties facade source 挂在链头, + // 其 containsProperty 委托给所有底层 source(任意 key 都命中), + // 不跳过会遮蔽真实来源,property_source 恒为 "configurationProperties" + addFileSource(NAMESPACE, GROUP, FILE_NAME, Collections.singletonMap("k", "1")); + environment.getPropertySources().addFirst(new MapPropertySource("configurationProperties", + Collections.singletonMap("k", "1"))); + + EffectiveValue value = provider.resolve("k", metadata(NAMESPACE, GROUP, FILE_NAME)); + + assertThat(value).isNotNull(); + assertThat(value.getPropertySource()).isEqualTo("polaris:default/order-service/application.yaml"); + } + + @Test + void testKeyNotPresentAnywhere() { + addFileSource(NAMESPACE, GROUP, FILE_NAME, Collections.singletonMap("other.key", "1")); + + EffectiveValue value = provider.resolve("missing.key", metadata(NAMESPACE, GROUP, FILE_NAME)); + + assertThat(value).isNotNull(); + assertThat(value.getFileValue()).isNull(); + assertThat(value.getEffectiveValue()).isNull(); + assertThat(value.getPropertySource()).isNull(); + } +}