diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f12c8435..2d1bfb712 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,4 +16,4 @@ - [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) - [feat: support encoding and decoding TSF headers without TSF Consul](https://github.com/Tencent/spring-cloud-tencent/pull/1816) -- [feat: mask config values of encrypted config files in logs](https://github.com/Tencent/spring-cloud-tencent/pull/1817) +- [feat: protect encrypted config values and improve config cache fallback](https://github.com/Tencent/spring-cloud-tencent/pull/1817) 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 8a7f5f234..6075d5f2e 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 @@ -17,6 +17,7 @@ package com.tencent.cloud.polaris.config; +import java.io.File; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -106,9 +107,10 @@ public class ConfigurationModifier implements PolarisConfigurationConfigModifier ConnectorConfigImpl connectorConfig = configuration.getConfigFile().getServerConnector(); // set connector type connectorConfig.setConnectorType(polarisConfigProperties.getDataSource()); + String localFileRootPath = polarisConfigProperties.getLocalFileRootPath(); + // The same directory stores local-source files and remote-source fallback caches. + connectorConfig.setPersistDir(localFileRootPath); if (StringUtils.equalsIgnoreCase(polarisConfigProperties.getDataSource(), LOCAL_FILE_CONNECTOR_TYPE)) { - String localFileRootPath = polarisConfigProperties.getLocalFileRootPath(); - connectorConfig.setPersistDir(localFileRootPath); LOGGER.info("[SCT] Run spring cloud tencent config with local data source. localFileRootPath = {}", localFileRootPath); return; } @@ -131,7 +133,7 @@ public class ConfigurationModifier implements PolarisConfigurationConfigModifier // enable close check address for unit tests if (polarisConfigProperties.isCheckAddress()) { - checkAddressAccessible(configAddresses); + checkAddressAccessible(configAddresses, connectorConfig.getFallbackToLocalCache()); } connectorConfig.setAddresses(configAddresses); @@ -218,7 +220,7 @@ public class ConfigurationModifier implements PolarisConfigurationConfigModifier return polarisAddresses; } - private void checkAddressAccessible(List configAddresses) { + private void checkAddressAccessible(List configAddresses, Boolean fallbackToLocalCache) { // check address can connect configAddresses.forEach(address -> { String[] ipPort; @@ -243,7 +245,13 @@ public class ConfigurationModifier implements PolarisConfigurationConfigModifier String errMsg = "Config server address (" + address + ") can not be connected. Please check your config in bootstrap.yml" + " with spring.cloud.polaris.address or spring.cloud.polaris.config.address."; if (polarisConfigProperties.isShutdownIfConnectToConfigServerFailed()) { - throw new IllegalArgumentException(errMsg); + if (Boolean.TRUE.equals(fallbackToLocalCache) && hasLocalConfigCache()) { + LOGGER.warn("{} Continue startup to fall back to cached config files in {}.", + errMsg, polarisConfigProperties.getLocalFileRootPath()); + } + else { + throw new IllegalArgumentException(errMsg); + } } else { LOGGER.error(errMsg); @@ -251,4 +259,15 @@ public class ConfigurationModifier implements PolarisConfigurationConfigModifier } }); } + + private boolean hasLocalConfigCache() { + String rootPath = polarisConfigProperties.getLocalFileRootPath(); + if (StringUtils.isBlank(rootPath)) { + return false; + } + // Polaris config cache file names follow namespace#group#fileName.yaml. + File[] files = new File(rootPath).listFiles(file -> file.isFile() + && file.getName().endsWith(".yaml") && file.getName().contains("#")); + return files != null && files.length > 0; + } } 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 3d2d50221..3e5ab44ec 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 @@ -86,7 +86,7 @@ public class PolarisConfigProperties { private String dataSource = "polaris"; /** - * The root path of config files, only used in local mode. + * The root path of local config files and remote config fallback caches. */ private String localFileRootPath = "./polaris/backup/config"; diff --git a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/endpoint/PolarisConfigEndpoint.java b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/endpoint/PolarisConfigEndpoint.java index 04788b6d0..d3bac8ade 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/endpoint/PolarisConfigEndpoint.java +++ b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/endpoint/PolarisConfigEndpoint.java @@ -17,14 +17,18 @@ package com.tencent.cloud.polaris.config.endpoint; -import java.util.HashMap; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import com.tencent.cloud.polaris.config.PolarisConfigSDKContextManager; import com.tencent.cloud.polaris.config.adapter.PolarisPropertySource; import com.tencent.cloud.polaris.config.adapter.PolarisPropertySourceManager; +import com.tencent.cloud.polaris.config.config.ConfigFileGroup; import com.tencent.cloud.polaris.config.config.PolarisConfigProperties; +import com.tencent.polaris.configuration.api.core.ConfigKVFile; +import com.tencent.polaris.configuration.client.internal.CompositeConfigFile; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; @@ -45,15 +49,97 @@ public class PolarisConfigEndpoint { @ReadOperation public Map polarisConfig() { - Map configInfo = new HashMap<>(); - configInfo.put("PolarisConfigProperties", polarisConfigProperties); + Map configInfo = new LinkedHashMap<>(); + configInfo.put("PolarisConfigProperties", configProperties()); + configInfo.put("PolarisPropertySource", propertySources()); + configInfo.put("ClientId", getClientId()); + return configInfo; + } - List propertySourceList = PolarisPropertySourceManager.getAllPropertySources(); - configInfo.put("PolarisPropertySource", propertySourceList); + private Map configProperties() { + Map properties = new LinkedHashMap<>(); + properties.put("enabled", polarisConfigProperties.isEnabled()); + properties.put("address", polarisConfigProperties.getAddress()); + properties.put("port", polarisConfigProperties.getPort()); + properties.put("autoRefresh", polarisConfigProperties.isAutoRefresh()); + properties.put("shutdownIfConnectToConfigServerFailed", + polarisConfigProperties.isShutdownIfConnectToConfigServerFailed()); + properties.put("preference", polarisConfigProperties.isPreference()); + properties.put("refreshType", polarisConfigProperties.getRefreshType()); + properties.put("groups", configFileGroups(polarisConfigProperties.getGroups())); + properties.put("dataSource", polarisConfigProperties.getDataSource()); + properties.put("localFileRootPath", polarisConfigProperties.getLocalFileRootPath()); + properties.put("internalEnabled", polarisConfigProperties.isInternalEnabled()); + properties.put("checkAddress", polarisConfigProperties.isCheckAddress()); + properties.put("emptyProtectionEnabled", polarisConfigProperties.isEmptyProtectionEnabled()); + properties.put("emptyProtectionExpiredInterval", polarisConfigProperties.getEmptyProtectionExpiredInterval()); - configInfo.put("ClientId", getClientId()); + PolarisConfigProperties.Report reportProperties = polarisConfigProperties.getReport(); + if (reportProperties != null) { + Map report = new LinkedHashMap<>(); + report.put("enabled", reportProperties.isEnabled()); + if (reportProperties.getEffective() != null) { + Map effective = new LinkedHashMap<>(); + effective.put("enabled", reportProperties.getEffective().isEnabled()); + report.put("effective", effective); + } + properties.put("report", report); + } + // token is intentionally omitted: an actuator endpoint must not expose credentials. + return properties; + } - return configInfo; + private List> configFileGroups(List groups) { + List> groupInfo = new ArrayList<>(); + if (groups == null) { + return groupInfo; + } + for (ConfigFileGroup group : groups) { + Map info = new LinkedHashMap<>(); + info.put("namespace", group.getNamespace()); + info.put("name", group.getName()); + info.put("files", group.getFiles() == null ? new ArrayList<>() : new ArrayList<>(group.getFiles())); + groupInfo.add(info); + } + return groupInfo; + } + + private List> propertySources() { + List> sources = new ArrayList<>(); + for (PolarisPropertySource source : PolarisPropertySourceManager.getAllPropertySources()) { + Map sourceInfo = new LinkedHashMap<>(); + sourceInfo.put("namespace", source.getNamespace()); + sourceInfo.put("group", source.getGroup()); + sourceInfo.put("fileName", source.getFileName()); + sourceInfo.put("propertyNames", new ArrayList<>(source.getSource().keySet())); + sourceInfo.put("configKVFile", configFileInfo(source.getConfigKVFile())); + sources.add(sourceInfo); + } + return sources; + } + + private Map configFileInfo(ConfigKVFile configFile) { + Map info = new LinkedHashMap<>(); + if (configFile == null) { + return info; + } + info.put("namespace", configFile.getNamespace()); + info.put("fileGroup", configFile.getFileGroup()); + info.put("fileName", configFile.getFileName()); + info.put("fileVersion", configFile.getFileVersion()); + info.put("propertyNames", configFile.getPropertyNames() == null + ? new ArrayList<>() : new ArrayList<>(configFile.getPropertyNames())); + if (configFile instanceof CompositeConfigFile) { + List> files = new ArrayList<>(); + List configFiles = ((CompositeConfigFile) configFile).getConfigKVFiles(); + if (configFiles != null) { + for (ConfigKVFile file : configFiles) { + files.add(configFileInfo(file)); + } + } + info.put("configKVFiles", files); + } + return info; } /** 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 874fd0497..8ffcdd5a1 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 @@ -83,7 +83,7 @@ "name": "spring.cloud.polaris.config.local-file-root-path", "type": "java.lang.String", "defaultValue": "./polaris/backup/config", - "description": "Where to load config file, polaris or local." + "description": "The root path of local config files and remote config fallback caches." }, { "name": "spring.cloud.polaris.config.report.enabled", 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 84558f28a..99fd4593a 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 @@ -18,6 +18,8 @@ package com.tencent.cloud.polaris.config; import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -51,6 +53,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -94,6 +97,9 @@ class ConfigurationModifierTest { @Mock private ConfigEffectiveQueryConfig configEffectiveCustomizer; + @TempDir + private Path tempDir; + private ConfigurationModifier configurationModifier; @BeforeEach @@ -318,6 +324,7 @@ class ConfigurationModifierTest { when(polarisContextProperties.getEnabled()).thenReturn(true); when(polarisConfigProperties.isEnabled()).thenReturn(true); when(polarisConfigProperties.getDataSource()).thenReturn("polaris"); + when(polarisConfigProperties.getLocalFileRootPath()).thenReturn("/tmp/polaris-config-cache"); when(polarisConfigProperties.getAddress()).thenReturn("grpc://127.0.0.1:8093"); when(polarisConfigProperties.isCheckAddress()).thenReturn(false); when(polarisConfigProperties.isEmptyProtectionEnabled()).thenReturn(true); @@ -343,6 +350,7 @@ class ConfigurationModifierTest { // Assert ConnectorConfigImpl connectorConfig = configuration.getConfigFile().getServerConnector(); + verify(connectorConfig).setPersistDir("/tmp/polaris-config-cache"); verify(connectorConfig).setAddresses(parsedAddresses); verify(connectorConfig).setLbPolicy("roundRobin"); verify(connectorConfig).setServerSwitchInterval(600000L); @@ -699,6 +707,54 @@ class ConfigurationModifierTest { } } + /** + * Test an inaccessible address when an existing local cache can be used. + * Scenario: checkAddress and shutdown are enabled, but SDK fallback is enabled and the + * configured persist directory contains a cache file. + * Expect: the preflight check does not abort startup, allowing the SDK to load the cache. + */ + @DisplayName("modify should continue to local cache when config address is unavailable") + @Test + void testModify_CheckAddressNotAccessibleWithLocalCacheFallback() throws Exception { + ConfigurationImpl configuration = buildMockConfiguration(); + ConnectorConfigImpl connectorConfig = configuration.getConfigFile().getServerConnector(); + Path cacheFile = Files.createFile(tempDir.resolve("default#group#application.properties.yaml")); + + when(polarisContextProperties.getEnabled()).thenReturn(true); + when(polarisConfigProperties.isEnabled()).thenReturn(true); + when(polarisConfigProperties.getDataSource()).thenReturn("polaris"); + when(polarisConfigProperties.getLocalFileRootPath()).thenReturn(tempDir.toString()); + when(polarisConfigProperties.getAddress()).thenReturn("grpc://127.0.0.1:1"); + when(polarisConfigProperties.isCheckAddress()).thenReturn(true); + when(polarisConfigProperties.isShutdownIfConnectToConfigServerFailed()).thenReturn(true); + when(polarisConfigProperties.isEmptyProtectionEnabled()).thenReturn(true); + when(polarisConfigProperties.getEmptyProtectionExpiredInterval()).thenReturn(604800000L); + when(polarisCryptoConfigProperties.isEnabled()).thenReturn(false); + when(polarisContextProperties.getAddress()).thenReturn("grpc://127.0.0.1:8091"); + when(polarisContextProperties.getAddressLbPolicy()).thenReturn("roundRobin"); + when(polarisContextProperties.getServerSwitchInterval()).thenReturn(600000L); + when(connectorConfig.getFallbackToLocalCache()).thenReturn(true); + + List parsedAddresses = Collections.singletonList("127.0.0.1:1"); + List parsedPolarisAddresses = Collections.singletonList("127.0.0.1:8091"); + try (MockedStatic mockedAddressUtils = Mockito.mockStatic(AddressUtils.class); + MockedStatic mockedTsf = Mockito.mockStatic(TsfContextUtils.class)) { + mockedAddressUtils.when(() -> AddressUtils.parseAddressList("grpc://127.0.0.1:1")) + .thenReturn(parsedAddresses); + mockedAddressUtils.when(() -> AddressUtils.parseAddressList("grpc://127.0.0.1:8091")) + .thenReturn(parsedPolarisAddresses); + mockedAddressUtils.when(() -> AddressUtils.accessible("127.0.0.1", 1, 3000)) + .thenReturn(false); + mockedTsf.when(TsfContextUtils::isOnlyTsfConsulEnabled).thenReturn(false); + + configurationModifier.modify(configuration); + + verify(connectorConfig).setPersistDir(tempDir.toString()); + verify(connectorConfig).setAddresses(parsedAddresses); + assertThat(cacheFile).exists(); + } + } + /** * Test modify with checkAddress enabled, address not accessible but shutdown disabled. * Scenario: checkAddress is true, accessible returns false, shutdownIfConnectToConfigServerFailed is false. diff --git a/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/endpoint/PolarisConfigEndpointTest.java b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/endpoint/PolarisConfigEndpointTest.java index 51bc92975..cd184b54a 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/endpoint/PolarisConfigEndpointTest.java +++ b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/endpoint/PolarisConfigEndpointTest.java @@ -17,10 +17,11 @@ package com.tencent.cloud.polaris.config.endpoint; -import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; +import com.fasterxml.jackson.databind.ObjectMapper; import com.tencent.cloud.polaris.config.PolarisConfigSDKContextManager; import com.tencent.cloud.polaris.config.adapter.MockedConfigKVFile; import com.tencent.cloud.polaris.config.adapter.PolarisPropertySource; @@ -28,6 +29,7 @@ import com.tencent.cloud.polaris.config.adapter.PolarisPropertySourceManager; import com.tencent.cloud.polaris.config.config.PolarisConfigProperties; import com.tencent.polaris.api.plugin.common.ValueContext; import com.tencent.polaris.client.api.SDKContext; +import com.tencent.polaris.configuration.client.internal.CompositeConfigFile; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -64,20 +66,57 @@ public class PolarisConfigEndpointTest { } @Test - public void testPolarisConfigEndpoint() { + @SuppressWarnings("unchecked") + public void testPolarisConfigEndpoint() throws Exception { + PolarisConfigProperties properties = new PolarisConfigProperties(); + properties.setToken("endpoint-must-not-expose-this-token"); Map content = new HashMap<>(); - content.put("k1", "v1"); - content.put("k2", "v2"); - content.put("k3", "v3"); + content.put("k1", "sensitive-value-one"); + content.put("k2", "sensitive-value-two"); + content.put("k3", "sensitive-value-three"); MockedConfigKVFile file = new MockedConfigKVFile(content); PolarisPropertySource polarisPropertySource = new PolarisPropertySource(testNamespace, testServiceName, testFileName, file, content); PolarisPropertySourceManager.addPropertySource(polarisPropertySource); - PolarisConfigEndpoint endpoint = new PolarisConfigEndpoint(polarisConfigProperties); + PolarisConfigEndpoint endpoint = new PolarisConfigEndpoint(properties); Map info = endpoint.polarisConfig(); - assertThat(polarisConfigProperties).isEqualTo(info.get("PolarisConfigProperties")); - assertThat(Collections.singletonList(polarisPropertySource)).isEqualTo(info.get("PolarisPropertySource")); + assertThat(info.get("PolarisConfigProperties")).isInstanceOf(Map.class); + List> sources = (List>) info.get("PolarisPropertySource"); + assertThat(sources).hasSize(1); + assertThat(sources.get(0)).containsEntry("namespace", testNamespace) + .containsEntry("group", testServiceName) + .containsEntry("fileName", testFileName); + + // Actuator serializes the return value. Keep it to plain DTO structures and never expose + // property values through this diagnostic endpoint. + String json = new ObjectMapper().writeValueAsString(info); + assertThat(json).contains("\"ClientId\":null", "\"propertyNames\":[") + .doesNotContain("sensitive-value-one", "sensitive-value-two", "sensitive-value-three", + "endpoint-must-not-expose-this-token"); + } + + @Test + @SuppressWarnings("unchecked") + public void testPolarisConfigEndpointExposesCompositeFileMetadata() { + MockedConfigKVFile first = new MockedConfigKVFile(Map.of("first.key", "first-value"), + "first.properties", testServiceName, testNamespace); + MockedConfigKVFile second = new MockedConfigKVFile(Map.of("second.key", "second-value"), + "second.properties", testServiceName, testNamespace); + CompositeConfigFile composite = new CompositeConfigFile(List.of(first, second)); + PolarisPropertySource source = new PolarisPropertySource(testNamespace, testServiceName, "", + composite, new HashMap<>()); + PolarisPropertySourceManager.addPropertySource(source); + + PolarisConfigEndpoint endpoint = new PolarisConfigEndpoint(polarisConfigProperties); + List> sources = + (List>) endpoint.polarisConfig().get("PolarisPropertySource"); + Map configKVFile = (Map) sources.get(0).get("configKVFile"); + List> files = + (List>) configKVFile.get("configKVFiles"); + + assertThat(files).extracting(file -> file.get("fileName")) + .containsExactly("first.properties", "second.properties"); } @Test